SXXXXXXX_FlightMonitor/flightmonitor/map/map_drawing.py

781 lines
29 KiB
Python

# 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],
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
current_stitched_map_pixel_shape: Optional[Tuple[int, int]],
):
"""Draw single flight marker, its track, and label on PIL ImageDraw."""
if not (PIL_LIB_AVAILABLE_DRAWING and CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING and ImageDraw and ImageColor):
return
if draw is None or pixel_coords is None or flight_state is None:
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"
margin = 100
if not (-margin <= px < img_w + margin and -margin <= py < img_h + margin):
return
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]
# --- Draw Track ---
if track_deque and len(track_deque) > 1:
num_points_to_draw = min(len(track_deque), TRACK_HISTORY_POINTS)
pixel_track_points: List[Tuple[int, int]] = []
for i in range(num_points_to_draw):
geo_lat, geo_lon, _ = track_deque[len(track_deque) - 1 - i]
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)
if len(pixel_track_points) > 1:
for i in range(len(pixel_track_points) - 1):
start_point_px, end_point_px = pixel_track_points[i], pixel_track_points[i + 1]
faded_color = _calculate_faded_color(flight_and_track_base_color_hex, i, len(pixel_track_points))
if faded_color:
draw.line([start_point_px, end_point_px], fill=faded_color, width=TRACK_LINE_WIDTH)
# --- 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)
tip_x, tip_y = px + side_length * math.cos(angle_rad), py - side_length * math.sin(angle_rad)
base_angle_offset = math.pi / 2
base1_x, base1_y = px + (base_length / 2) * math.cos(angle_rad + base_angle_offset), py - (base_length / 2) * math.sin(angle_rad + base_angle_offset)
base2_x, base2_y = px + (base_length / 2) * math.cos(angle_rad - base_angle_offset), 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")
except Exception as e:
logger.error(f"Error drawing triangle for flight '{label_text}': {e}", exc_info=False)
# --- Draw Label (Revised Logic) ---
if label_font:
try:
# Define the anchor point for the label: right of the plane, vertically centered.
label_anchor_x = px + side_length + 5
label_anchor_y = py # Vertically aligned with the aircraft's center
# Use textbbox with anchor "lm" (left-middle) to get the exact bounding box
# of the text as it will be rendered. This is the key for perfect alignment.
text_bbox = draw.textbbox(
(label_anchor_x, label_anchor_y),
label_text,
font=label_font,
anchor="lm" # Anchor to the left-middle of the text
)
# Draw the background rectangle based on the precise bounding box, with padding.
bg_padding = 2
bg_coords = [
text_bbox[0] - bg_padding,
text_bbox[1] - bg_padding,
text_bbox[2] + bg_padding,
text_bbox[3] + bg_padding,
]
draw.rectangle(bg_coords, fill=map_constants.TILE_TEXT_BG_COLOR)
# Draw the text itself using the same anchor point and method.
# Pillow will handle the perfect vertical centering.
draw.text(
(label_anchor_x, label_anchor_y),
label_text,
fill=map_constants.TILE_TEXT_COLOR,
font=label_font,
anchor="lm"
)
except Exception as e:
logger.error(f"Error drawing label for '{label_text}': {e}", exc_info=False)