# flightmonitor/map/map_canvas_manager.py import tkinter as tk import math import time # Aggiunto per il calcolo dell'età della traccia from typing import Optional, Tuple, List, Dict, Any from collections import deque try: from PIL import Image, ImageTk, ImageDraw, ImageFont PIL_IMAGE_LIB_AVAILABLE = True except ImportError: Image, ImageTk, ImageDraw, ImageFont = None, None, None, None # type: ignore PIL_IMAGE_LIB_AVAILABLE = False import logging logging.error( "MapCanvasManager: Pillow (Image, ImageTk, ImageDraw, ImageFont) not found. Map disabled." ) try: import pyproj PYPROJ_MODULE_LOCALLY_AVAILABLE = True except ImportError: pyproj = None # type: ignore PYPROJ_MODULE_LOCALLY_AVAILABLE = False import logging logging.warning( "MapCanvasManager: 'pyproj' not found. Geographic calculations impaired." ) try: import mercantile MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError: mercantile = None # type: ignore MERCANTILE_MODULE_LOCALLY_AVAILABLE = False import logging logging.error( "MapCanvasManager: 'mercantile' not found. Tile conversions fail, map unusable." ) from . import map_constants from ..data import config as app_config from ..data.common_models import CanonicalFlightState from .map_services import BaseMapService, OpenStreetMapService from .map_tile_manager import ( MapTileManager, ) # MODIFIED: Changed from map_manager to map_tile_manager from .map_utils import ( get_tile_ranges_for_bbox, calculate_geographic_bbox_size_km, calculate_geographic_bbox_from_pixel_size_and_zoom, deg_to_dms_string, _is_valid_bbox_dict, _pixel_to_geo, calculate_meters_per_pixel, calculate_zoom_level_for_geographic_size, get_bounding_box_from_center_size, ) from . import map_drawing try: from ..utils.logger import get_logger logger = get_logger(__name__) except ImportError: import logging logger = logging.getLogger(__name__) logger.warning("MapCanvasManager using fallback standard Python logger.") CANVAS_SIZE_HARD_FALLBACK_PX = getattr(app_config, "DEFAULT_CANVAS_WIDTH", 800) # MODIFIED: Referenced map_constants for MAP_TILE_CACHE_DIR # WHY: Ensure consistency with where MAP_TILE_CACHE_DIR is defined (expected in map_constants or app_config) # HOW: Changed app_config to map_constants. Assuming it's defined there. If it's in app_config, this should revert. MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr( map_constants, "MAP_TILE_CACHE_DIR", "flightmonitor_tile_cache_fallback" ) RESIZE_DEBOUNCE_DELAY_MS = 150 PAN_STEP_FRACTION = 0.25 DEFAULT_MAX_TRACK_POINTS = 20 DEFAULT_MAX_TRACK_AGE_SECONDS = 300 class MapCanvasManager: def __init__( self, app_controller: Any, tk_canvas: tk.Canvas, initial_bbox_dict: Dict[str, float], ): logger.info("Initializing MapCanvasManager...") if ( not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None ): critical_msg = "MapCanvasManager critical dependencies missing: Pillow or Mercantile. Map disabled." logger.critical(critical_msg) if app_controller and hasattr(app_controller, "show_error_message"): try: app_controller.show_error_message( "Map Initialization Error", critical_msg ) except Exception: pass raise ImportError(critical_msg) self.app_controller = app_controller self.canvas = tk_canvas self.canvas_width = self.canvas.winfo_width() if self.canvas_width <= 1: self.canvas_width = CANVAS_SIZE_HARD_FALLBACK_PX self.canvas_height = self.canvas.winfo_height() if self.canvas_height <= 1: self.canvas_height = getattr(app_config, "DEFAULT_CANVAS_HEIGHT", 600) if self.canvas_width <= 0 or self.canvas_height <= 0: logger.critical( f"MapCanvasManager init with invalid canvas dims ({self.canvas_width}x{self.canvas_height}) after fallbacks." ) raise ValueError("Invalid canvas dimensions for MapCanvasManager.") self._current_center_lat: Optional[float] = None self._current_center_lon: Optional[float] = None self._current_zoom: int = map_constants.DEFAULT_INITIAL_ZOOM self._current_map_geo_bounds: Optional[Tuple[float, float, float, float]] = None self._map_pil_image: Optional[Image.Image] = None self._map_photo_image: Optional[ImageTk.PhotoImage] = None self._canvas_image_id: Optional[int] = None self.map_service: BaseMapService = OpenStreetMapService() self.tile_manager: MapTileManager = MapTileManager( map_service=self.map_service, cache_root_directory=MAP_TILE_CACHE_DIR_HARD_FALLBACK, tile_pixel_size=self.map_service.tile_size, ) logger.info( f"MapTileManager initialized for service '{self.tile_manager.service_identifier_name}'." ) self._target_bbox_input: Optional[Dict[str, float]] = None self._current_flights_to_display: List[CanonicalFlightState] = [] self.flight_tracks: Dict[str, deque[Tuple[float, float, float]]] = {} self.max_track_points: int = DEFAULT_MAX_TRACK_POINTS self.max_track_age_seconds: float = DEFAULT_MAX_TRACK_AGE_SECONDS self._resize_debounce_job_id: Optional[str] = None if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict): self._target_bbox_input = initial_bbox_dict.copy() self.update_map_view_for_bbox( initial_bbox_dict, preserve_current_zoom_if_possible=False ) else: logger.warning( f"Invalid initial_bbox_dict: {initial_bbox_dict}. Using default fallback view." ) default_bbox_cfg = { "lat_min": app_config.DEFAULT_BBOX_LAT_MIN, "lon_min": app_config.DEFAULT_BBOX_LON_MIN, "lat_max": app_config.DEFAULT_BBOX_LAT_MAX, "lon_max": app_config.DEFAULT_BBOX_LON_MAX, } if _is_valid_bbox_dict(default_bbox_cfg): self._target_bbox_input = default_bbox_cfg.copy() self.set_target_bbox(default_bbox_cfg) else: logger.critical( f"Default fallback BBox from config is invalid: {default_bbox_cfg}. Cannot initialize map view." ) self._target_bbox_input = None self._current_center_lat = getattr( app_config, "DEFAULT_MAP_CENTER_LAT", 45.0 ) self._current_center_lon = getattr( app_config, "DEFAULT_MAP_CENTER_LON", 9.0 ) self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM self.recenter_and_redraw( self._current_center_lat, self._current_center_lon, self._current_zoom, ) self._setup_event_bindings() logger.info( f"MapCanvasManager initialized for canvas size {self.canvas_width}x{self.canvas_height}." ) def _setup_event_bindings(self): self.canvas.bind("", self._on_canvas_resize) self.canvas.bind("", self._on_left_button_press) self.canvas.bind("", self._on_left_button_release) self.canvas.bind("", self._on_right_click) self._drag_start_x_canvas: Optional[int] = None self._drag_start_y_canvas: Optional[int] = None self._is_left_button_pressed: bool = False def _on_canvas_resize(self, event: tk.Event): new_width, new_height = event.width, event.height if ( new_width > 1 and new_height > 1 and (self.canvas_width != new_width or self.canvas_height != new_height) ): if self._resize_debounce_job_id: try: self.canvas.after_cancel(self._resize_debounce_job_id) except Exception: pass finally: self._resize_debounce_job_id = None self._resize_debounce_job_id = self.canvas.after( RESIZE_DEBOUNCE_DELAY_MS, self._perform_resize_redraw, new_width, new_height, ) def _perform_resize_redraw(self, width: int, height: int): self._resize_debounce_job_id = None if not self.canvas.winfo_exists(): return logger.info( f"Performing debounced resize redraw for dimensions {width}x{height}." ) self.canvas_width, self.canvas_height = width, height if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input): logger.debug("Refitting map view to target BBox after resize.") self.update_map_view_for_bbox( self._target_bbox_input, preserve_current_zoom_if_possible=True ) elif ( self._current_center_lat is not None and self._current_center_lon is not None and self._current_zoom is not None ): logger.debug("Recentering map view at current geo center after resize.") self.recenter_and_redraw( self._current_center_lat, self._current_center_lon, self._current_zoom ) else: logger.warning( "No valid BBox or geo center after resize. Cannot redraw effectively." ) def set_max_track_points(self, length: int): new_length = max(2, length) if self.max_track_points != new_length: logger.info( f"MapCanvasManager: Max track points updating from {self.max_track_points} to {new_length}" ) self.max_track_points = new_length for icao in list(self.flight_tracks.keys()): track_deque = self.flight_tracks.get(icao) if track_deque: while len(track_deque) > self.max_track_points: track_deque.popleft() if not track_deque: if icao in self.flight_tracks: del self.flight_tracks[icao] if self.canvas.winfo_exists() and self._map_pil_image: logger.debug("Forcing map redraw after track length change.") self._redraw_canvas_content() else: logger.debug( f"MapCanvasManager: Max track points already set to {new_length}. No change." ) def set_target_bbox(self, new_bbox_dict: Dict[str, float]): logger.info(f"MapCanvasManager: New target BBox requested: {new_bbox_dict}") if new_bbox_dict and _is_valid_bbox_dict(new_bbox_dict): self._target_bbox_input = new_bbox_dict.copy() self.update_map_view_for_bbox( self._target_bbox_input, preserve_current_zoom_if_possible=False ) if self.app_controller and hasattr( self.app_controller, "update_bbox_gui_fields" ): self.app_controller.update_bbox_gui_fields(self._target_bbox_input) else: logger.warning( f"Invalid/empty new_bbox_dict provided: {new_bbox_dict}. Clearing target BBox." ) self._target_bbox_input = None if ( self._current_center_lat is not None and self._current_center_lon is not None and self._current_zoom is not None ): self.recenter_and_redraw( self._current_center_lat, self._current_center_lon, self._current_zoom, ) else: self.clear_map_display() if self.app_controller and hasattr( self.app_controller, "update_bbox_gui_fields" ): self.app_controller.update_bbox_gui_fields({}) def update_map_view_for_bbox( self, target_bbox_dict: Dict[str, float], preserve_current_zoom_if_possible: bool = False, ): if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict): logger.warning( "update_map_view_for_bbox called with invalid/no target BBox." ) return lat_min, lon_min, lat_max, lon_max = ( target_bbox_dict["lat_min"], target_bbox_dict["lon_min"], target_bbox_dict["lat_max"], target_bbox_dict["lon_max"], ) view_center_lat, view_center_lon = (lat_min + lat_max) / 2.0, ( lon_min + lon_max ) / 2.0 if lon_min > lon_max: view_center_lon = (lon_min + (lon_max + 360.0)) / 2.0 if view_center_lon >= 180.0: view_center_lon -= 360.0 zoom_to_use = self._current_zoom if not preserve_current_zoom_if_possible or zoom_to_use is None: patch_width_km, patch_height_km = calculate_geographic_bbox_size_km( (lon_min, lat_min, lon_max, lat_max) ) or (None, None) if ( patch_width_km and patch_height_km and self.canvas_width > 0 and self.canvas_height > 0 ): zoom_w = calculate_zoom_level_for_geographic_size( view_center_lat, patch_width_km * 1000, self.canvas_width, self.tile_manager.tile_size, ) zoom_h = calculate_zoom_level_for_geographic_size( view_center_lat, patch_height_km * 1000, self.canvas_height, self.tile_manager.tile_size, ) if zoom_w is not None and zoom_h is not None: zoom_to_use = min(zoom_w, zoom_h) elif zoom_w is not None: zoom_to_use = zoom_w elif zoom_h is not None: zoom_to_use = zoom_h else: zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM logger.info( f"Calculated zoom to fit BBox: {zoom_to_use} (based on W:{zoom_w}, H:{zoom_h})" ) else: logger.warning( "Could not calculate dimensions or canvas size for BBox zoom. Using default." ) zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM max_zoom = ( self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK ) zoom_to_use = max(map_constants.MIN_ZOOM_LEVEL, min(zoom_to_use, max_zoom)) self.recenter_and_redraw( view_center_lat, view_center_lon, zoom_to_use, ensure_bbox_is_covered_dict=target_bbox_dict, ) def recenter_and_redraw( self, center_lat: float, center_lon: float, zoom_level: int, ensure_bbox_is_covered_dict: Optional[Dict[str, float]] = None, ): logger.info( f"Recentering map. Center: ({center_lat:.4f}, {center_lon:.4f}), Zoom: {zoom_level}. Ensure BBox Covered: {'Yes' if ensure_bbox_is_covered_dict else 'No'}" ) if ( not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None ): logger.error("Pillow or Mercantile not available. Cannot recenter/redraw.") self._clear_canvas_display() return self._current_center_lat, self._current_center_lon = center_lat, center_lon max_zoom = ( self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK ) self._current_zoom = max( map_constants.MIN_ZOOM_LEVEL, min(zoom_level, max_zoom) ) if self.canvas_width <= 0 or self.canvas_height <= 0: logger.error( f"Canvas dims invalid ({self.canvas_width}x{self.canvas_height}). Cannot redraw." ) self._clear_canvas_display() return canvas_geo_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom( self._current_center_lat, self._current_center_lon, self.canvas_width, self.canvas_height, self._current_zoom, self.tile_manager.tile_size, ) if not canvas_geo_bbox: logger.error( "Failed to calculate canvas geographic BBox. Cannot fetch tiles." ) self._clear_canvas_display() return fetch_bounds_for_tiles = canvas_geo_bbox if ensure_bbox_is_covered_dict and _is_valid_bbox_dict( ensure_bbox_is_covered_dict ): logger.debug( f"Ensuring BBox {ensure_bbox_is_covered_dict} is covered. Current canvas geo BBox: {canvas_geo_bbox}" ) tile_xy_ranges = get_tile_ranges_for_bbox( fetch_bounds_for_tiles, self._current_zoom ) if not tile_xy_ranges: logger.error( f"Failed to get tile ranges for {fetch_bounds_for_tiles} at zoom {self._current_zoom}. Cannot draw." ) self._clear_canvas_display() if ( PIL_IMAGE_LIB_AVAILABLE and Image is not None and ImageDraw is not None and self.canvas.winfo_exists() ): try: placeholder_img = Image.new( "RGB", (self.canvas_width, self.canvas_height), map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB, ) draw = ImageDraw.Draw(placeholder_img) map_drawing._draw_text_on_placeholder( draw, placeholder_img.size, "Map Error\nCannot get tiles." ) self._map_photo_image = ImageTk.PhotoImage(placeholder_img) # type: ignore if self.canvas.winfo_exists(): self._canvas_image_id = self.canvas.create_image( 0, 0, anchor=tk.NW, image=self._map_photo_image ) except Exception as e: logger.error( f"Failed to draw tile range error placeholder: {e}", exc_info=True, ) return stitched_map_pil = self.tile_manager.stitch_map_image( self._current_zoom, tile_xy_ranges[0], tile_xy_ranges[1] ) if not stitched_map_pil: logger.error("Failed to stitch map image.") self._clear_canvas_display() return self._current_map_geo_bounds = self.tile_manager._get_bounds_for_tile_range( self._current_zoom, tile_xy_ranges ) if not self._current_map_geo_bounds: logger.warning( "Could not determine actual stitched map geo bounds. Using fetch bounds as fallback." ) self._current_map_geo_bounds = fetch_bounds_for_tiles self._map_pil_image = stitched_map_pil if self._map_pil_image and PIL_IMAGE_LIB_AVAILABLE: self._redraw_canvas_content() if self.app_controller and hasattr( self.app_controller, "update_general_map_info" ): self.app_controller.update_general_map_info() def _redraw_canvas_content(self): logger.debug("MapCanvasManager: _redraw_canvas_content called.") if not PIL_IMAGE_LIB_AVAILABLE or Image is None or ImageDraw is None: logger.warning( "_redraw_canvas_content: Pillow/ImageDraw not available. Cannot draw." ) self._clear_canvas_display() return self._clear_canvas_display() if self._map_pil_image is None or self._current_map_geo_bounds is None: logger.warning( "No base map image or geo bounds. Canvas cleared, skipping overlay drawing." ) return if self._map_pil_image.mode != "RGBA": base_map_rgba = self._map_pil_image.convert("RGBA") image_to_draw_on = base_map_rgba.copy() else: image_to_draw_on = self._map_pil_image.copy() img_shape = image_to_draw_on.size draw = ImageDraw.Draw(image_to_draw_on) # A. Disegna il BBox target (se definito) if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input): logger.debug(f"Drawing target BBox on map: {self._target_bbox_input}") bbox_wesn = ( self._target_bbox_input["lon_min"], self._target_bbox_input["lat_min"], self._target_bbox_input["lon_max"], self._target_bbox_input["lat_max"], ) try: # MODIFIED: Pass image_to_draw_on (PIL.Image.Image) instead of draw (PIL.ImageDraw.ImageDraw) # The function map_drawing.draw_area_bounding_box expects an Image object # and creates its own ImageDraw instance internally. map_drawing.draw_area_bounding_box( image_to_draw_on, bbox_wesn, self._current_map_geo_bounds, img_shape, color=map_constants.AREA_BOUNDARY_COLOR, thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX, ) except Exception as e: logger.error(f"Error drawing target BBox: {e}", exc_info=True) # B. Disegna le Tracce degli Aerei for icao, track_deque in self.flight_tracks.items(): if len(track_deque) < 2: continue pixel_points_for_track: List[Tuple[int, int]] = [] for lat, lon, _ts in track_deque: pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map( lat, lon, self._current_map_geo_bounds, img_shape ) if pixel_coords: pixel_points_for_track.append(pixel_coords) if len(pixel_points_for_track) >= 2: try: track_color_str = "orange" track_width = 1 draw.line( pixel_points_for_track, fill=track_color_str, width=track_width ) except Exception as e_track: logger.error( f"Error drawing track for ICAO {icao}: {e_track}", exc_info=False, ) # C. Disegna i Marker degli Aerei flights_drawn_count = 0 if ( self._current_flights_to_display and ImageDraw is not None and map_drawing is not None ): font_size = map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE if self._current_zoom is not None: font_size += self._current_zoom - map_constants.DEM_TILE_LABEL_BASE_ZOOM label_font = map_drawing._load_label_font(font_size) for flight in self._current_flights_to_display: if flight.latitude is not None and flight.longitude is not None: pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map( flight.latitude, flight.longitude, self._current_map_geo_bounds, img_shape, ) if pixel_coords: try: flight_marker_color = "red" map_drawing._draw_single_flight( draw, pixel_coords, flight, label_font, flight_base_color_str=flight_marker_color, ) flights_drawn_count += 1 except Exception as e_flight: logger.error( f"Error drawing flight {flight.icao24}: {e_flight}", exc_info=False, ) logger.debug( f"Drew {flights_drawn_count} of {len(self._current_flights_to_display)} flight markers." ) elif self._current_flights_to_display: logger.warning( "ImageDraw or map_drawing module not available, skipping drawing flights." ) # D. Crea PhotoImage e aggiorna il canvas try: if ImageTk: self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on) else: logger.error("Pillow ImageTk missing. Cannot create PhotoImage.") self._clear_canvas_display() return except Exception as e: logger.error(f"Failed to create PhotoImage: {e}", exc_info=True) self._clear_canvas_display() return if self.canvas.winfo_exists(): try: self._canvas_image_id = self.canvas.create_image( 0, 0, anchor=tk.NW, image=self._map_photo_image ) except tk.TclError as e: logger.warning( f"TclError drawing canvas image: {e}. GUI likely gone.", exc_info=False, ) self._canvas_image_id = None except Exception as e: logger.error( f"Unexpected error drawing canvas image: {e}", exc_info=True ) self._canvas_image_id = None else: logger.debug("_redraw_canvas_content: Canvas does not exist.") def _clear_canvas_display(self): if self._canvas_image_id is not None and self.canvas.winfo_exists(): try: self.canvas.delete(self._canvas_image_id) except Exception: pass finally: self._canvas_image_id = None self._map_photo_image = None def clear_map_display(self): logger.info( "MapCanvasManager: Clearing all map content and resetting view state." ) self._clear_canvas_display() self._map_pil_image = None self._current_flights_to_display = [] self.flight_tracks.clear() self._current_map_geo_bounds = None self._target_bbox_input = None if self.canvas.winfo_exists(): try: self.canvas.delete("placeholder_text") except Exception: pass if self.app_controller and hasattr( self.app_controller, "update_general_map_info" ): self.app_controller.update_general_map_info() def _on_left_button_press(self, event: tk.Event): if not self.canvas.winfo_exists(): return self._drag_start_x_canvas, self._drag_start_y_canvas = event.x, event.y self._is_left_button_pressed = True def _on_left_button_release(self, event: tk.Event): if not self.canvas.winfo_exists() or not self._is_left_button_pressed: self._is_left_button_pressed = False return self._is_left_button_pressed = False if self._current_map_geo_bounds is not None and self._map_pil_image is not None: map_pixel_shape = self._map_pil_image.size clicked_lon, clicked_lat = _pixel_to_geo( event.x, event.y, self._current_map_geo_bounds, map_pixel_shape ) if clicked_lon is not None and clicked_lat is not None: logger.debug( f"Map Left-Clicked at Geo ({clicked_lat:.5f}, {clicked_lon:.5f}) - Canvas ({event.x},{event.y})" ) if self.app_controller and hasattr( self.app_controller, "on_map_left_click" ): try: self.app_controller.on_map_left_click( clicked_lat, clicked_lon, event.x_root, event.y_root ) except Exception as e: logger.error( f"Error calling controller left click handler: {e}", exc_info=False, ) else: logger.warning( f"Failed to convert left click pixel ({event.x},{event.y}) to geo." ) else: logger.warning("Map context missing for left click geo conversion.") self._drag_start_x_canvas, self._drag_start_y_canvas = None, None def _on_right_click(self, event: tk.Event): if not self.canvas.winfo_exists(): return if self._current_map_geo_bounds is None or self._map_pil_image is None: logger.warning("Map context missing for right click geo conversion.") return map_pixel_shape = self._map_pil_image.size geo_lon, geo_lat = _pixel_to_geo( event.x, event.y, self._current_map_geo_bounds, map_pixel_shape ) if geo_lon is not None and geo_lat is not None: logger.info(f"Map Right-Clicked at Geo ({geo_lat:.5f}, {geo_lon:.5f})") if self.app_controller and hasattr( self.app_controller, "on_map_right_click" ): try: self.app_controller.on_map_right_click( geo_lat, geo_lon, event.x_root, event.y_root ) except Exception as e: logger.error( f"Error calling controller right click handler: {e}", exc_info=False, ) else: logger.warning( f"Failed to convert right click pixel ({event.x},{event.y}) to geo." ) def update_flights_on_map(self, flight_states: List[CanonicalFlightState]): logger.debug( f"MapCanvasManager: Update flights received with {len(flight_states)} states. Current max_track_points: {self.max_track_points}" ) current_time = time.time() active_icao_this_update = set() for state in flight_states: if ( state.latitude is not None and state.longitude is not None and state.timestamp is not None ): active_icao_this_update.add(state.icao24) if state.icao24 not in self.flight_tracks: self.flight_tracks[state.icao24] = deque() self.flight_tracks[state.icao24].append( (state.latitude, state.longitude, state.timestamp) ) while len(self.flight_tracks[state.icao24]) > self.max_track_points: self.flight_tracks[state.icao24].popleft() else: logger.debug( f"Skipping flight state for track update due to missing geo/ts: {state.icao24}" ) tracks_to_remove = [] for icao, track_deque in self.flight_tracks.items(): if not track_deque: tracks_to_remove.append(icao) continue last_point_time = track_deque[-1][2] is_inactive_in_current_update = icao not in active_icao_this_update is_track_too_old = ( current_time - last_point_time > self.max_track_age_seconds ) if is_inactive_in_current_update and is_track_too_old: logger.debug( f"Removing old and inactive track for {icao}. Last seen: {current_time - last_point_time:.0f}s ago." ) tracks_to_remove.append(icao) elif not flight_states and is_track_too_old: logger.debug( f"Removing old track for {icao} (no active flights). Last seen: {current_time - last_point_time:.0f}s ago." ) tracks_to_remove.append(icao) for icao in tracks_to_remove: if icao in self.flight_tracks: del self.flight_tracks[icao] self._current_flights_to_display = flight_states if self.canvas.winfo_exists() and self._map_pil_image: self._redraw_canvas_content() elif not self._map_pil_image: logger.debug( "No base map image, flights/tracks will be drawn on next full redraw." ) def get_current_map_info(self) -> Dict[str, Any]: map_size_km_w, map_size_km_h = None, None if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj and self._current_map_geo_bounds: try: size_km_tuple = calculate_geographic_bbox_size_km( self._current_map_geo_bounds ) if size_km_tuple: map_size_km_w, map_size_km_h = size_km_tuple except Exception as e: logger.error(f"Error calc current map geo size: {e}", exc_info=False) return { "center_lat": self._current_center_lat, "center_lon": self._current_center_lon, "zoom": self._current_zoom, "map_geo_bounds": self._current_map_geo_bounds, "target_bbox_input": self._target_bbox_input, "canvas_width": self.canvas_width, "canvas_height": self.canvas_height, "map_size_km_w": map_size_km_w, "map_size_km_h": map_size_km_h, "flight_count": len(self._current_flights_to_display), } def show_map_context_menu_from_gui( self, latitude: float, longitude: float, screen_x: int, screen_y: int ): logger.info( f"MapCanvasManager: Showing context menu for click @ Geo ({latitude:.4f}, {longitude:.4f})" ) if not self.canvas.winfo_exists(): return root_widget = self.canvas.winfo_toplevel() try: context_menu = tk.Menu(root_widget, tearoff=0) decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5) context_menu.add_command( label=f"Context @ {latitude:.{decimals}f},{longitude:.{decimals}f}", state=tk.DISABLED, ) context_menu.add_separator() if self.app_controller: if hasattr(self.app_controller, "recenter_map_at_coords"): context_menu.add_command( label="Center map here", command=lambda: self.app_controller.recenter_map_at_coords( latitude, longitude ), ) if hasattr(self.app_controller, "set_bbox_around_coords"): area_km_cfg_name = "DEFAULT_CLICK_AREA_SIZE_KM" area_km = getattr(self.app_controller, area_km_cfg_name, 50.0) context_menu.add_command( label=f"Set {area_km:.0f}km Mon. Area", command=lambda: self.app_controller.set_bbox_around_coords( latitude, longitude, area_km ), ) context_menu.tk_popup(screen_x, screen_y) except tk.TclError as e: logger.warning(f"TclError showing MapCanvasManager menu: {e}.") except Exception as e: logger.error( f"Error creating/showing MapCanvasManager menu: {e}", exc_info=True ) def recenter_map_at_coords(self, lat: float, lon: float): logger.info( f"MapCanvasManager: Request to recenter map @ Geo ({lat:.4f}, {lon:.4f})" ) if ( self._current_zoom is not None and self.canvas.winfo_exists() and PIL_IMAGE_LIB_AVAILABLE and MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile is not None ): self.recenter_and_redraw(lat, lon, self._current_zoom) else: logger.warning( "Cannot recenter map: missing context (zoom, canvas, or libs)." ) def set_bbox_around_coords( self, center_lat: float, center_lon: float, area_size_km: float ): logger.info( f"MapCanvasManager: Request to set BBox around Geo ({center_lat:.4f}, {center_lon:.4f}), size {area_size_km:.1f}km." ) if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("Cannot set BBox around coords: pyproj library not available.") if self.app_controller and hasattr( self.app_controller, "show_error_message" ): self.app_controller.show_error_message( "Map Error", "Cannot calculate BBox: Geographic libraries missing." ) return try: bbox_tuple_wesn = get_bounding_box_from_center_size( center_lat, center_lon, area_size_km ) if bbox_tuple_wesn: bbox_dict = { "lon_min": bbox_tuple_wesn[0], "lat_min": bbox_tuple_wesn[1], "lon_max": bbox_tuple_wesn[2], "lat_max": bbox_tuple_wesn[3], } if _is_valid_bbox_dict(bbox_dict): self.set_target_bbox(bbox_dict) else: logger.error( f"Calculated BBox around coords is invalid: {bbox_dict}." ) if self.app_controller and hasattr( self.app_controller, "show_error_message" ): self.app_controller.show_error_message( "Map Error", "Calculated BBox is invalid." ) else: logger.error( f"Failed to calculate BBox around coords ({center_lat}, {center_lon}, {area_size_km}km)." ) if self.app_controller and hasattr( self.app_controller, "show_error_message" ): self.app_controller.show_error_message( "Map Error", "Failed to calculate BBox around coordinates." ) except Exception as e: logger.exception(f"Unexpected error calculating BBox around coords: {e}") if self.app_controller and hasattr( self.app_controller, "show_error_message" ): self.app_controller.show_error_message( "Map Error", f"An unexpected error occurred calculating BBox: {e}" ) def zoom_in_at_center(self): if ( self._current_zoom is None or self._current_center_lat is None or self._current_center_lon is None ): logger.warning( "Cannot zoom in: current map state (zoom/center) is not defined." ) return if ( not self.canvas.winfo_exists() or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE ): logger.warning( "Cannot zoom in: canvas or required libraries not available." ) return max_zoom = ( self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK ) new_zoom = min(self._current_zoom + 1, max_zoom) if new_zoom != self._current_zoom: logger.info( f"Zooming in from {self._current_zoom} to {new_zoom} at current center." ) self.recenter_and_redraw( self._current_center_lat, self._current_center_lon, new_zoom ) else: logger.debug( f"Already at max zoom ({self._current_zoom}). Cannot zoom in further." ) def zoom_out_at_center(self): if ( self._current_zoom is None or self._current_center_lat is None or self._current_center_lon is None ): logger.warning( "Cannot zoom out: current map state (zoom/center) is not defined." ) return if ( not self.canvas.winfo_exists() or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE ): logger.warning( "Cannot zoom out: canvas or required libraries not available." ) return new_zoom = max(map_constants.MIN_ZOOM_LEVEL, self._current_zoom - 1) if new_zoom != self._current_zoom: logger.info( f"Zooming out from {self._current_zoom} to {new_zoom} at current center." ) self.recenter_and_redraw( self._current_center_lat, self._current_center_lon, new_zoom ) else: logger.debug( f"Already at min zoom ({self._current_zoom}). Cannot zoom out further." ) def pan_map_fixed_step( self, direction: str, step_fraction: float = PAN_STEP_FRACTION ): if ( self._current_center_lat is None or self._current_center_lon is None or self._current_zoom is None or self._current_map_geo_bounds is None or self._map_pil_image is None ): logger.warning( "Cannot pan map: current map state or image not fully defined." ) return if ( not self.canvas.winfo_exists() or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None ): logger.warning( "Cannot pan map: canvas or required libraries not available." ) return delta_x_pixels, delta_y_pixels = 0, 0 pan_step_px_w = int(self.canvas_width * step_fraction) pan_step_px_h = int(self.canvas_height * step_fraction) if direction == "left": delta_x_pixels = pan_step_px_w elif direction == "right": delta_x_pixels = -pan_step_px_w elif direction == "up": delta_y_pixels = pan_step_px_h elif direction == "down": delta_y_pixels = -pan_step_px_h else: logger.warning(f"Unknown pan direction: {direction}") return map_west, map_south, map_east, map_north = self._current_map_geo_bounds current_view_center_lon = (map_west + map_east) / 2.0 current_view_center_lat = (map_south + map_north) / 2.0 if map_west > map_east: current_view_center_lon = (map_west + map_east + 360) / 2.0 if current_view_center_lon > 180: current_view_center_lon -= 360 res_m_px = calculate_meters_per_pixel( current_view_center_lat, self._current_zoom, self.tile_manager.tile_size ) if res_m_px is None or res_m_px <= 1e-9: logger.error( "Could not calculate valid resolution for panning. Cannot pan." ) return delta_meters_x = delta_x_pixels * res_m_px delta_meters_y = delta_y_pixels * res_m_px geod = pyproj.Geod(ellps="WGS84") new_center_lon, new_center_lat = ( self._current_center_lon, self._current_center_lat, ) if abs(delta_meters_x) > 1e-9: azimuth_lon = 90.0 if delta_meters_x > 0 else 270.0 clamped_start_lat = max(-89.99, min(89.99, new_center_lat)) temp_lon, _, _ = geod.fwd( new_center_lon, clamped_start_lat, azimuth_lon, abs(delta_meters_x) ) new_center_lon = temp_lon if abs(delta_meters_y) > 1e-9: azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0 clamped_start_lon = max(-179.99, min(179.99, new_center_lon)) _, temp_lat, _ = geod.fwd( clamped_start_lon, new_center_lat, azimuth_lat, abs(delta_meters_y) ) new_center_lat = temp_lat MAX_MERCATOR_LAT = 85.05112878 new_center_lat = max(-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, new_center_lat)) new_center_lon = (new_center_lon + 180) % 360 - 180 logger.info( f"Panning map '{direction}'. New target center: ({new_center_lat:.4f}, {new_center_lon:.4f})" ) self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom) def center_map_and_fit_patch( self, center_lat: float, center_lon: float, patch_size_km: float ): logger.info( f"Request to center map at ({center_lat:.4f}, {center_lon:.4f}) and fit patch of {patch_size_km}km." ) if ( not self.canvas.winfo_exists() or self.canvas_width <= 0 or self.canvas_height <= 0 ): logger.error("Cannot fit patch: canvas not ready or invalid dimensions.") if self.app_controller and hasattr( self.app_controller, "show_error_message" ): self.app_controller.show_error_message( "Map Error", "Canvas not ready to fit patch." ) return if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error( "Cannot fit patch: PyProj library not available for geographic calculations." ) if self.app_controller and hasattr( self.app_controller, "show_error_message" ): self.app_controller.show_error_message( "Map Error", "Geographic library (PyProj) missing for patch fitting.", ) return zoom_for_width = calculate_zoom_level_for_geographic_size( center_lat, patch_size_km * 1000, self.canvas_width, self.tile_manager.tile_size, ) zoom_for_height = calculate_zoom_level_for_geographic_size( center_lat, patch_size_km * 1000, self.canvas_height, self.tile_manager.tile_size, ) if zoom_for_width is None or zoom_for_height is None: logger.error( f"Could not calculate zoom to fit patch of {patch_size_km}km. Using current or default zoom." ) new_zoom = ( self._current_zoom if self._current_zoom is not None else map_constants.DEFAULT_INITIAL_ZOOM ) else: new_zoom = min(zoom_for_width, zoom_for_height) max_zoom_limit = ( self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK ) new_zoom = max(map_constants.MIN_ZOOM_LEVEL, min(new_zoom, max_zoom_limit)) logger.info( f"Centering and fitting patch. Target center: ({center_lat:.4f}, {center_lon:.4f}), Calculated Zoom: {new_zoom}" ) self.recenter_and_redraw(center_lat, center_lon, new_zoom)