# flightmonitor/map/map_drawing.py import logging import math from typing import Optional, Tuple, List, Dict, Any from collections import deque # Aggiunto per il type hinting di track_deque try: from PIL import Image, ImageDraw, ImageFont, ImageColor PIL_LIB_AVAILABLE_DRAWING = True except ImportError: Image = None # type: ignore ImageDraw = None # type: ignore ImageFont = None # type: ignore ImageColor = None # type: ignore PIL_LIB_AVAILABLE_DRAWING = False import logging logging.error("MapDrawing: Pillow not found. Drawing disabled.") try: import cv2 import numpy as np CV2_NUMPY_LIBS_AVAILABLE_DRAWING = True except ImportError: cv2 = None # type: ignore np = None # type: ignore CV2_NUMPY_LIBS_AVAILABLE_DRAWING = False import logging logging.warning("MapDrawing: OpenCV or NumPy not found. Some drawing ops disabled.") try: import mercantile MERCANTILE_LIB_AVAILABLE_DRAWING = True except ImportError: mercantile = None # type: ignore MERCANTILE_LIB_AVAILABLE_DRAWING = False import logging logging.error("MapDrawing: 'mercantile' not found. Conversions fail.") from .map_utils import get_hgt_tile_geographic_bounds # type: ignore from .map_utils import MapCalculationError # type: ignore from .map_utils import MERCANTILE_MODULE_LOCALLY_AVAILABLE # type: ignore try: from ..data.common_models import CanonicalFlightState CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING = True except ImportError: CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING = False class CanonicalFlightState: # type: ignore pass import logging logging.warning("MapDrawing: CanonicalFlightState not found.") try: from ..utils.logger import get_logger logger = get_logger(__name__) except ImportError: logger = logging.getLogger(__name__) logger.warning("MapDrawing: App logger not available.") try: from . import map_constants from ..data import config as app_config # Importa app_config MAP_CONSTANTS_AVAILABLE_DRAWING = True # Leggi i valori da app_config, con fallback a map_constants o valori hardcoded TRACK_HISTORY_POINTS = getattr( app_config, "DEFAULT_TRACK_HISTORY_POINTS", getattr(map_constants, "DEFAULT_TRACK_HISTORY_POINTS", 20), ) TRACK_LINE_WIDTH = getattr( app_config, "DEFAULT_TRACK_LINE_WIDTH", getattr(map_constants, "DEFAULT_TRACK_LINE_WIDTH", 2), ) except ImportError: MAP_CONSTANTS_AVAILABLE_DRAWING = False logger = logging.getLogger(__name__) logger.warning( "MapDrawing: map_constants or app_config not available. Using hardcoded drawing defaults." ) class MockMapConstantsDrawing: # Definizione di fallback per map_constants DEM_BOUNDARY_COLOR = "red" DEM_BOUNDARY_THICKNESS_PX = 3 AREA_BOUNDARY_COLOR = "blue" AREA_BOUNDARY_THICKNESS_PX = 2 TILE_TEXT_COLOR = "white" TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)" DEM_TILE_LABEL_BASE_FONT_SIZE = 12 DEM_TILE_LABEL_BASE_ZOOM = 10 DEFAULT_LABEL_FONT_PATH = None COORDINATE_DECIMAL_PLACES = 4 TRACK_COLOR_PALETTE = [ "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#800000", "#008000", "#000080", "#808000", "#800080", "#008080", "#C0C0C0", "#400000", "#004000", "#000040", "#404000", "#400040", "#004040", "#202020", "#FF8000", "#80FF00", "#00FF80", "#8000FF", "#FF0080", "#0080FF", "#808080", "#A0522D", "#D2691E", "#DAA520", "#BDB76B", "#8FBC8F", "#FA8072", "#E9967A", "#F08080", "#CD5C5C", "#DC143C", "#B22222", "#FF69B4", "#FF1493", "#C71585", "#DB7093", "#E6E6FA", "#D8BFD8", "#DDA0DD", "#EE82EE", "#9932CC", "#8A2BE2", "#BA55D3", "#9370DB", "#7B68EE", "#6A5ACD", "#483D8B", "#708090", "#778899", "#B0C4DE", "#ADD8E6", "#87CEEB", "#87CEFA", "#00BFFF", "#1E90FF", "#6495ED", "#4682B4", "#0000CD", "#00008B", ] DEFAULT_TRACK_HISTORY_POINTS = 20 DEFAULT_TRACK_LINE_WIDTH = 2 map_constants = MockMapConstantsDrawing() TRACK_HISTORY_POINTS = map_constants.DEFAULT_TRACK_HISTORY_POINTS TRACK_LINE_WIDTH = map_constants.DEFAULT_TRACK_LINE_WIDTH # --- Helper for Geo to Pixel Conversion on Unscaled Stitched Image --- def _geo_to_pixel_on_unscaled_map( latitude_deg: float, longitude_deg: float, current_map_geo_bounds: Optional[Tuple[float, float, float, float]], current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height) ) -> Optional[Tuple[int, int]]: """Convert geographic to pixel on unscaled stitched image. Requires mercantile.""" logger.debug( f"Attempting to convert geo ({latitude_deg:.5f}, {longitude_deg:.5f}) to pixel." ) if not MERCANTILE_LIB_AVAILABLE_DRAWING or mercantile is None: logger.error( "_geo_to_pixel_on_unscaled_map: Mercantile missing or not available." ) return None if current_map_geo_bounds is None: logger.warning("_geo_to_pixel_on_unscaled_map: Map geo bounds missing.") return None if current_stitched_map_pixel_shape is None: logger.warning("_geo_to_pixel_on_unscaled_map: Map pixel shape missing.") return None if not (-90.0 <= latitude_deg <= 90.0 and -180.0 <= longitude_deg <= 180.0): logger.debug( f"Input geo ({latitude_deg:.4f}, {longitude_deg:.4f}) out of range. Clamping." ) latitude_deg = max(-85.05112878, min(85.05112878, latitude_deg)) longitude_deg = (longitude_deg + 180.0) % 360.0 - 180.0 logger.debug(f"Clamped geo: ({latitude_deg:.4f}, {longitude_deg:.4f})") img_w, img_h = current_stitched_map_pixel_shape map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds logger.debug( f"Map geo bounds used: W={map_west_lon}, S={map_south_lat}, E={map_east_lon}, N={map_north_lat}" ) logger.debug(f"Stitched image pixel shape: {img_w}x{img_h}") if img_w <= 0 or img_h <= 0: logger.warning(f"Map pixel dims invalid ({img_w}x{img_h}). Cannot convert.") return None try: map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) total_map_height_merc = abs( map_ul_merc_y - map_lr_merc_y ) # Mercator Y decreases with increasing latitude logger.debug( f"Map Mercator extent: UL=({map_ul_merc_x:.2f}, {map_ul_merc_y:.2f}), LR=({map_lr_merc_x:.2f}, {map_lr_merc_y:.2f})" ) logger.debug( f"Total Mercator size: W={total_map_width_merc:.2f}, H={total_map_height_merc:.2f}" ) if total_map_width_merc <= 1e-9 or total_map_height_merc <= 1e-9: logger.warning( f"Map mercator dims zero/near-zero ({total_map_width_merc:.2e}x{total_map_height_merc:.2e}). Cannot convert." ) return None target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) logger.debug( f"Target Geo ({longitude_deg:.5f}, {latitude_deg:.5f}) in Mercator: ({target_merc_x:.2f}, {target_merc_y:.2f})" ) relative_x_on_mercator = ( (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 ) relative_y_on_mercator = ( (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 ) pixel_x_on_unscaled_raw = relative_x_on_mercator * img_w pixel_y_on_unscaled_raw = relative_y_on_mercator * img_h logger.debug( f"Relative Mercator ({relative_x_on_mercator:.4f}, {relative_y_on_mercator:.4f}) -> Raw Pixel ({pixel_x_on_unscaled_raw:.2f}, {pixel_y_on_unscaled_raw:.2f})" ) rounded_pixel_x = int(round(pixel_x_on_unscaled_raw)) rounded_pixel_y = int(round(pixel_y_on_unscaled_raw)) clamp_margin = 10 px_clamped = max(-clamp_margin, min(rounded_pixel_x, img_w + clamp_margin)) py_clamped = max(-clamp_margin, min(rounded_pixel_y, img_h + clamp_margin)) logger.debug( f"Rounded Pixel ({rounded_pixel_x}, {rounded_pixel_y}) -> Clamped Pixel ({px_clamped}, {py_clamped})" ) logger.debug( f"Geo ({latitude_deg:.5f}, {longitude_deg:.5f}) -> Pixel ({px_clamped}, {py_clamped})" ) return (px_clamped, py_clamped) except Exception as e_geo_to_px_unscaled: logger.error( f"Error during geo-to-pixel conversion for geo ({latitude_deg:.5f}, {longitude_deg:.5f}): {e_geo_to_px_unscaled}", exc_info=True, ) return None # --- Drawing Functions --- def draw_point_marker( pil_image_to_draw_on: Image.Image, latitude_deg: float, longitude_deg: float, current_map_geo_bounds: Optional[Tuple[float, float, float, float]], current_stitched_map_pixel_shape: Optional[Tuple[int, int]], ) -> Image.Image: """Draw point marker on PIL Image. Requires OpenCV/NumPy.""" if ( not PIL_LIB_AVAILABLE_DRAWING or not CV2_NUMPY_LIBS_AVAILABLE_DRAWING or cv2 is None or np is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None ): logger.warning("Cannot draw marker: Libs/context missing.") return pil_image_to_draw_on pixel_coords_on_unscaled = _geo_to_pixel_on_unscaled_map( latitude_deg, longitude_deg, current_map_geo_bounds, current_stitched_map_pixel_shape, ) if pixel_coords_on_unscaled: px_clamped, py_clamped = pixel_coords_on_unscaled img_w, img_h = current_stitched_map_pixel_shape px_strict = max(0, min(px_clamped, img_w - 1)) py_strict = max(0, min(py_clamped, img_h - 1)) if cv2 and np: try: if pil_image_to_draw_on.mode != "RGBA": if Image: pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") else: return pil_image_to_draw_on map_cv_rgba = np.array(pil_image_to_draw_on) map_cv_bgr = cv2.cvtColor(map_cv_rgba, cv2.COLOR_RGBA2BGR) cv2.drawMarker( map_cv_bgr, (px_strict, py_strict), (0, 0, 255), cv2.MARKER_CROSS, markerSize=15, thickness=2, ) if Image: return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) else: return pil_image_to_draw_on except Exception as e: logger.exception(f"Error drawing marker with OpenCV: {e}") return pil_image_to_draw_on else: logger.warning("OpenCV or NumPy not available for drawing point marker.") return pil_image_to_draw_on else: logger.debug( f"Geo-to-pixel failed for ({latitude_deg:.5f},{longitude_deg:.5f}), cannot draw marker." ) return pil_image_to_draw_on def draw_area_bounding_box( pil_image_to_draw_on: Image.Image, area_geo_bbox: Tuple[float, float, float, float], current_map_geo_bounds: Optional[Tuple[float, float, float, float]], current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height) color: str = map_constants.AREA_BOUNDARY_COLOR, thickness: int = map_constants.AREA_BOUNDARY_THICKNESS_PX, ) -> Image.Image: """Draw BBox on PIL Image. Requires PIL/ImageDraw/Mercantile.""" logger.debug(f"Attempting to draw area bounding box {area_geo_bbox}.") if ( not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None ): logger.warning("Cannot draw BBox: Libs/context missing.") return pil_image_to_draw_on if not MERCANTILE_LIB_AVAILABLE_DRAWING or mercantile is None: logger.error("Mercantile missing or not available for BBox drawing.") return pil_image_to_draw_on west, south, east, north = area_geo_bbox corners_geo = [(west, north), (east, north), (east, south), (west, south)] pixel_corners: List[Tuple[int, int]] = [] try: img_w, img_h = current_stitched_map_pixel_shape for lon, lat in corners_geo: pixel_coords = _geo_to_pixel_on_unscaled_map( lat, lon, current_map_geo_bounds, current_stitched_map_pixel_shape ) logger.debug( f"Converted BBox corner geo ({lat:.5f}, {lon:.5f}) to pixel {pixel_coords}." ) if pixel_coords: margin = max(img_w, img_h) px, py = pixel_coords if not ( -margin <= px < img_w + margin and -margin <= py < img_h + margin ): logger.debug( f"BBox corner pixel ({px},{py}) outside generous drawing margin [{ -margin }:{ img_w + margin }, { -margin }:{ img_h + margin }]. Skipping BBox draw." ) return pil_image_to_draw_on pixel_corners.append(pixel_coords) else: logger.warning( f"Failed to convert corner geo ({lat:.4f}, {lon:.4f}). Aborting BBox draw." ) return pil_image_to_draw_on except Exception as e: logger.exception(f"Error geo-to-pixel for BBox corners: {e}") return pil_image_to_draw_on if len(pixel_corners) == 4 and ImageDraw is not None: if pil_image_to_draw_on.mode not in ("RGB", "RGBA"): if Image: pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") else: logger.error("Pillow Image missing for RGBA conversion for BBox.") return pil_image_to_draw_on draw = ImageDraw.Draw(pil_image_to_draw_on) try: draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness) draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness) draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness) draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness) logger.debug(f"Drew lines for BBox corners: {pixel_corners}") except Exception as e: logger.exception(f"Error drawing BBox lines: {e}") return pil_image_to_draw_on else: logger.warning( f"Not 4 pixel corners ({len(pixel_corners)}) or ImageDraw missing. Cannot draw BBox." ) return pil_image_to_draw_on def draw_dem_tile_boundary( pil_image_to_draw_on: Image.Image, dem_tile_geo_bbox: Tuple[float, float, float, float], current_map_geo_bounds: Optional[Tuple[float, float, float, float]], current_map_render_zoom: Optional[int], current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height) ) -> Image.Image: """Draw DEM tile boundary on PIL Image. Uses map_constants.""" if ( not PIL_LIB_AVAILABLE_DRAWING or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_map_render_zoom is None or current_stitched_map_pixel_shape is None ): logger.warning("Cannot draw DEM boundary: PIL/context missing.") return pil_image_to_draw_on return draw_area_bounding_box( pil_image_to_draw_on, dem_tile_geo_bbox, current_map_geo_bounds, current_stitched_map_pixel_shape, color=map_constants.DEM_BOUNDARY_COLOR, thickness=map_constants.DEM_BOUNDARY_THICKNESS_PX, ) def _load_label_font( scaled_font_size: int, ) -> Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont]: """Load TrueType font or default PIL font.""" if not PIL_LIB_AVAILABLE_DRAWING or ImageFont is None: logger.warning("_load_label_font: Pillow/ImageFont missing.") return None font_to_use: Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont] = None scaled_font_size = max(1, scaled_font_size) if ( hasattr(map_constants, "DEFAULT_LABEL_FONT_PATH") and map_constants.DEFAULT_LABEL_FONT_PATH ): try: if hasattr(ImageFont, "truetype"): font_to_use = ImageFont.truetype( map_constants.DEFAULT_LABEL_FONT_PATH, scaled_font_size ) return font_to_use else: logger.warning("ImageFont.truetype not available.") except FileNotFoundError: logger.warning( f"Label font file not found: {map_constants.DEFAULT_LABEL_FONT_PATH}. Falling back." ) except Exception as e: logger.warning( f"Error loading TrueType font: {e}. Falling back.", exc_info=False ) try: if hasattr(ImageFont, "load_default"): font_to_use = ImageFont.load_default() else: logger.warning("ImageFont.load_default not available.") return None except Exception as e: logger.warning(f"Error loading default PIL font: {e}.") font_to_use = None if font_to_use is None: logger.error("Failed to load any font for labels.") return font_to_use def _draw_text_on_placeholder( draw: ImageDraw.ImageDraw, image_size: Tuple[int, int], text_to_draw: str ): """Draw text on placeholder image.""" img_width, img_height = image_size if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or ImageFont is None: logger.warning( "Pillow/ImageDraw/ImageFont missing, cannot draw text on placeholder." ) return font_to_use: Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont] = None try: if hasattr(ImageFont, "load_default"): font_to_use = ImageFont.load_default() else: logger.warning("ImageFont.load_default not available for placeholder.") except Exception as e: logger.warning(f"Error loading default PIL font for placeholder: {e}.") if font_to_use is None: logger.warning("No font available for drawing placeholder text. Drawing basic.") try: draw.text((10, 10), text_to_draw, fill="black") except Exception as e: logger.error(f"Error drawing basic placeholder text: {e}", exc_info=False) return try: text_width, text_height = 0, 0 if hasattr(draw, "textbbox"): try: text_bbox = draw.textbbox( (0, 0), text_to_draw, font=font_to_use, spacing=2 ) text_width, text_height = ( text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1], ) except Exception as e: logger.warning( f"Error textbbox placeholder: {e}. Fallback textsize.", exc_info=False, ) if (text_width == 0 or text_height == 0) and hasattr(draw, "textsize"): try: text_width, text_height = draw.textsize(text_to_draw, font=font_to_use) except Exception as e: logger.warning(f"Error textsize placeholder: {e}.", exc_info=False) text_width, text_height = 0, 0 if text_width > 0 and text_height > 0: text_x = (img_width - text_width) // 2 text_y = (img_height - text_height) // 2 bg_padding = 1 bg_coords = [ text_x - bg_padding, text_y - bg_padding, text_x + text_width + bg_padding, text_y + text_height + bg_padding, ] draw.rectangle(bg_coords, fill=map_constants.TILE_TEXT_BG_COLOR) draw.text( (text_x, text_y), text_to_draw, fill=map_constants.TILE_TEXT_COLOR, font=font_to_use, anchor="lt", ) logger.debug(f"Drew text on placeholder at pixel ({text_x}, {text_y}).") else: logger.warning( "Zero text size determined for placeholder text, skipping text drawing." ) except Exception as e_draw_text: logger.error( f"Error drawing text on placeholder image: {e_draw_text}", exc_info=True ) try: draw.text((10, 10), text_to_draw, fill="black") logger.debug("Drew basic text on placeholder (error during font draw).") except Exception as e_fallback_draw: logger.error( f"Error drawing basic text on placeholder after font error: {e_fallback_draw}", exc_info=False, ) def _calculate_faded_color( base_color_hex: str, index_from_end: int, total_visible_points: int, min_intensity: float = 0.1, ) -> Optional[Tuple[int, int, int, int]]: """ Calculates a faded RGBA color based on index from the end of the track. index_from_end = 0 is the point closest to the aircraft (most recent). total_visible_points is the number of points actually being drawn for the track. """ if not (0 <= index_from_end < total_visible_points): # This can happen if total_visible_points is 1, and index_from_end is 0, then (total_visible_points - 1 - index_from_end) is 0. # The ratio should be based on how far back from the *current* point it is. # If total_visible_points is 1, it's the current point, full intensity. # If total_visible_points is 2, current is index 0 (ratio 1.0), previous is index 1 (ratio min_intensity) pass # Allow index_from_end == total_visible_points -1 try: if not ImageColor: logger.warning("_calculate_faded_color: ImageColor not available.") return None r, g, b = ImageColor.getrgb(base_color_hex) # Get RGB tuple if total_visible_points <= 1: # Single point or no history intensity = 1.0 else: # Intensity decreases as we go further back in history (index_from_end increases) # Point closest to aircraft (index_from_end = 0) has max_intensity # Point furthest from aircraft (index_from_end = total_visible_points - 1) has min_intensity ratio = ( (total_visible_points - 1 - index_from_end) / (total_visible_points - 1) if total_visible_points > 1 else 1.0 ) intensity = min_intensity + (1.0 - min_intensity) * ratio intensity = max(min_intensity, min(1.0, intensity)) # Clamp final_alpha = int(intensity * 255) faded_color_tuple = (r, g, b, final_alpha) logger.debug( f"Faded color for index {index_from_end}/{total_visible_points-1}: {faded_color_tuple} from base {base_color_hex} with intensity {intensity:.2f}" ) return faded_color_tuple except Exception as e: logger.error( f"Error interpolating color for '{base_color_hex}': {e}", exc_info=False ) # Fallback to a default non-transparent color if fading fails try: if ImageColor: r_fb, g_fb, b_fb = ImageColor.getrgb(base_color_hex) return (r_fb, g_fb, b_fb, 255) except: return (128, 128, 128, 255) # Grey if everything fails def _draw_single_flight( draw: ImageDraw.ImageDraw, pixel_coords: Tuple[int, int], flight_state: CanonicalFlightState, label_font: Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont], track_deque: Optional[deque], # Pass the specific track deque for this flight current_map_geo_bounds: Optional[ Tuple[float, float, float, float] ], # For track point conversion current_stitched_map_pixel_shape: Optional[ Tuple[int, int] ], # For track point conversion ): """Draw single flight marker, its track, and label on PIL ImageDraw.""" global TRACK_HISTORY_POINTS, TRACK_LINE_WIDTH # Use global config values if not CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING: logger.warning("_draw_single_flight: CanonicalFlightState not available.") return if ( not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or ImageFont is None or ImageColor is None ): logger.warning("_draw_single_flight: Pillow components missing.") return if draw is None or pixel_coords is None or flight_state is None: logger.warning("_draw_single_flight called with missing essential arguments.") return px, py = pixel_coords base_length = 8 side_length = base_length * 1.5 img_w, img_h = draw.im.size label_text = ( flight_state.callsign.strip() if flight_state.callsign and flight_state.callsign.strip() else flight_state.icao24 ) if not label_text.strip(): label_text = "N/A" logger.debug( f"Drawing flight '{label_text}' (ICAO: {flight_state.icao24}) at pixel ({px},{py})." ) margin = 100 # Margin for drawing aircraft symbol if not (-margin <= px < img_w + margin and -margin <= py < img_h + margin): logger.debug( f"Flight pixel coords ({px},{py}) outside drawing margin. Skipping draw for '{label_text}'." ) return # Determine base color for this flight (triangle and its track) color_index = hash(flight_state.icao24) % len(map_constants.TRACK_COLOR_PALETTE) flight_and_track_base_color_hex = map_constants.TRACK_COLOR_PALETTE[color_index] logger.debug( f"MAP_DRAWING: _draw_single_flight for {flight_state.icao24} at px {pixel_coords}. Track deque length: {len(track_deque) if track_deque else 'None'}" ) # --- Draw Track First (so aircraft is on top) --- if track_deque and len(track_deque) > 1: # Convert all relevant geo points in track_deque to pixel points # Limit to TRACK_HISTORY_POINTS num_points_to_draw = min(len(track_deque), TRACK_HISTORY_POINTS) pixel_track_points: List[Tuple[int, int]] = [] # Iterate from most recent to oldest relevant point in the deque for i in range(num_points_to_draw): geo_lat, geo_lon, _timestamp = track_deque[ len(track_deque) - 1 - i ] # Last element is most recent track_px_coords = _geo_to_pixel_on_unscaled_map( geo_lat, geo_lon, current_map_geo_bounds, current_stitched_map_pixel_shape, ) if track_px_coords: pixel_track_points.append(track_px_coords) else: logger.debug( f"Could not convert track point {i} for {flight_state.icao24} to pixel." ) # pixel_track_points are now ordered from most recent (closest to aircraft) to oldest if len(pixel_track_points) > 1: logger.debug( f"MAP_DRAWING ({flight_state.icao24}): Will draw {len(pixel_track_points)-1} line segments." ) for i in range(len(pixel_track_points) - 1): start_point_px = pixel_track_points[i] end_point_px = pixel_track_points[i + 1] # index_from_end for color: i=0 is closest to aircraft, should be most intense faded_color = _calculate_faded_color( flight_and_track_base_color_hex, i, len(pixel_track_points) ) logger.debug( f"MAP_DRAWING ({flight_state.icao24}): Segment {i}, Start: {start_point_px}, End: {end_point_px}, Color: {faded_color}" ) # LOG AGGIUNTO if faded_color: try: draw.line( [start_point_px, end_point_px], fill=faded_color, width=TRACK_LINE_WIDTH, ) except Exception as e_draw_line: logger.error( f"Error drawing track line for {flight_state.icao24}: {e_draw_line}", exc_info=False, ) else: logger.warning( f"Could not calculate faded color for track segment for {flight_state.icao24}" ) logger.debug( f"Drew {len(pixel_track_points)-1} track segments for {flight_state.icao24}" ) # --- Draw Aircraft Triangle --- try: angle_deg = ( flight_state.true_track_deg if flight_state.true_track_deg is not None else 0.0 ) angle_rad = math.radians( 90.0 - angle_deg ) # Adjust for PIL's coordinate system (0 deg is East, positive is CCW) # Tip of the triangle (points in direction of flight) tip_x = px + side_length * math.cos(angle_rad) tip_y = py - side_length * math.sin(angle_rad) # PIL Y is inverted # Base corners (perpendicular to the direction vector) base_angle_offset = math.pi / 2 base1_x = px + (base_length / 2) * math.cos(angle_rad + base_angle_offset) base1_y = py - (base_length / 2) * math.sin(angle_rad + base_angle_offset) base2_x = px + (base_length / 2) * math.cos(angle_rad - base_angle_offset) base2_y = py - (base_length / 2) * math.sin(angle_rad - base_angle_offset) triangle_coords = [(tip_x, tip_y), (base1_x, base1_y), (base2_x, base2_y)] draw.polygon( triangle_coords, fill=flight_and_track_base_color_hex, outline="black" ) logger.debug( f"Drew triangle for flight '{label_text}' at ({px},{py}) with color {flight_and_track_base_color_hex}." ) except Exception as e: logger.error( f"Error drawing triangle for flight '{label_text}': {e}", exc_info=False ) # --- Draw Label --- if label_font is not None: try: text_width, text_height = 0, 0 # Simplified text size calculation if hasattr(draw, "textlength"): # Pillow 9.2.0+ text_width = draw.textlength(label_text, font=label_font) # For height, approximation or use fixed value if textlength doesn't give it. # Or use textbbox for more accuracy if available and needed. # For simplicity, let's try to get ascent/descent if possible. if hasattr(label_font, "getmetrics"): ascent, descent = label_font.getmetrics() text_height = ascent + descent elif hasattr( draw, "textbbox" ): # Fallback to textbbox if getmetrics not available bbox = draw.textbbox((0, 0), label_text, font=label_font) text_height = bbox[3] - bbox[1] if text_width == 0: text_width = bbox[2] - bbox[0] # If textlength wasn't available else: # Basic fallback logger.debug("Using approximate text height for label.") text_height = ( scaled_font_size if "scaled_font_size" in locals() else 10 ) if text_width == 0 and hasattr( draw, "textbbox" ): # If textlength failed or not available bbox = draw.textbbox((0, 0), label_text, font=label_font) text_width = bbox[2] - bbox[0] if text_height == 0: text_height = bbox[3] - bbox[1] if text_width > 0 and text_height > 0: text_anchor_x = px + side_length + 3 # Adjust based on triangle size text_anchor_y = py - (text_height // 2) clamp_margin_text = 5 label_text_x = max( -clamp_margin_text, min(text_anchor_x, img_w - text_width - clamp_margin_text), ) label_text_y = max( -clamp_margin_text, min(text_anchor_y, img_h - text_height - clamp_margin_text), ) bg_padding = 1 bg_coords = [ label_text_x - bg_padding, label_text_y - bg_padding, label_text_x + text_width + bg_padding, label_text_y + text_height + bg_padding, ] draw.rectangle(bg_coords, fill=map_constants.TILE_TEXT_BG_COLOR) draw.text( (label_text_x, label_text_y), label_text, fill=map_constants.TILE_TEXT_COLOR, font=label_font, anchor="lt", ) logger.debug(f"Drew text label '{label_text}'.") else: logger.warning( f"Zero text size for label '{label_text}', skipping text." ) except Exception as e: logger.error(f"Error drawing label for '{label_text}': {e}", exc_info=False) else: logger.debug(f"No label font for flight '{label_text}', skipping label.")