# flightmonitor/map/map_canvas_manager.py import tkinter as tk import math import time from typing import Optional, Tuple, List, Dict, Any from collections import deque import queue # For Queue and Empty import threading from . import map_utils import copy 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 from .map_utils import ( get_tile_ranges_for_bbox, calculate_geographic_bbox_size_km, calculate_geographic_bbox_from_pixel_size_and_zoom, _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 # Importa il modulo 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) MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr( app_config, "MAP_TILE_CACHE_DIR", "flightmonitor_tile_cache_fallback" ) RESIZE_DEBOUNCE_DELAY_MS = 250 PAN_STEP_FRACTION = 0.25 DEFAULT_MAX_TRACK_POINTS = getattr( app_config, "DEFAULT_TRACK_HISTORY_POINTS", 20 ) # Legge da config DEFAULT_MAX_TRACK_AGE_SECONDS = 300 # Puoi rendere anche questo configurabile se vuoi MAP_WORKER_QUEUE_TIMEOUT_S = 0.1 GUI_RESULT_POLL_INTERVAL_MS = 50 RENDER_REQUEST_TYPE_MAP = "render_map" RENDER_REQUEST_TYPE_SHUTDOWN = "shutdown_worker" class MapCanvasManager: def __init__( self, app_controller: Any, tk_canvas: tk.Canvas, initial_bbox_dict: Dict[str, float], ): logger.info(">>> MapCanvasManager __init__ STARTING <<<") 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) logger.info( f"MCM __init__: Canvas dims {self.canvas_width}x{self.canvas_height}" ) self._current_center_lat_gui: Optional[float] = None self._current_center_lon_gui: Optional[float] = None self._current_zoom_gui: int = map_constants.DEFAULT_INITIAL_ZOOM self._current_map_geo_bounds_gui: Optional[ Tuple[float, float, float, float] ] = None self._target_bbox_input_gui: Optional[Dict[str, float]] = None self._map_photo_image: Optional[ImageTk.PhotoImage] = None self._canvas_image_id: Optional[int] = None self._placeholder_text_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, # Questo ora usa app_config tile_pixel_size=self.map_service.tile_size, ) logger.info(f"MCM __init__: MapTileManager initialized.") self._current_flights_to_display_gui: List[CanonicalFlightState] = [] self.flight_tracks_gui: Dict[str, deque] = ( {} ) # Deque conterrà tuple (lat, lon, timestamp) self.max_track_points: int = DEFAULT_MAX_TRACK_POINTS # Inizializzato da config self.max_track_age_seconds: float = DEFAULT_MAX_TRACK_AGE_SECONDS self._resize_debounce_job_id: Optional[str] = None self._map_render_request_queue: queue.Queue = queue.Queue(maxsize=5) self._map_render_result_queue: queue.Queue = queue.Queue(maxsize=5) self._map_worker_stop_event: threading.Event = threading.Event() self._map_worker_thread: Optional[threading.Thread] = None self._gui_after_id_result_processor: Optional[str] = None self._last_render_request_id: int = 0 self._expected_render_id_gui: int = 0 self._map_data_lock: threading.Lock = ( threading.Lock() ) # Lock per dati condivisi logger.info("MCM __init__: All attributes initialized.") logger.info("MCM __init__: Attempting to start map worker thread...") self._start_map_worker_thread() logger.info(f"MCM __init__: Processing initial_bbox_dict: {initial_bbox_dict}") if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict): self._target_bbox_input_gui = initial_bbox_dict.copy() logger.info( f"MCM __init__: Valid initial_bbox_dict provided. Requesting render for bbox." ) self._request_map_render_for_bbox( initial_bbox_dict, preserve_current_zoom_if_possible=False ) else: logger.warning( f"MCM __init__: Invalid or no 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_gui = default_bbox_cfg.copy() logger.info( f"MCM __init__: Using default config BBox. Requesting render for this bbox." ) self._request_map_render_for_bbox( self._target_bbox_input_gui, preserve_current_zoom_if_possible=False ) else: logger.critical( f"MCM __init__: Default fallback BBox from config is invalid: {default_bbox_cfg}. Map cannot initialize view with specific bbox." ) self._target_bbox_input_gui = None self._current_center_lat_gui = getattr( app_config, "DEFAULT_MAP_CENTER_LAT", 45.0 ) self._current_center_lon_gui = getattr( app_config, "DEFAULT_MAP_CENTER_LON", 9.0 ) self._current_zoom_gui = map_constants.DEFAULT_INITIAL_ZOOM logger.info( f"MCM __init__: Using hardcoded default center/zoom. Requesting render." ) self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, ) self._setup_event_bindings() logger.info("MCM __init__: Event bindings set up.") logger.info("MCM __init__: Attempting to start GUI result processing...") self._start_gui_result_processing() logger.info(">>> MapCanvasManager __init__ FINISHED <<<") def _start_map_worker_thread(self): if self._map_worker_thread is not None and self._map_worker_thread.is_alive(): logger.warning("Map worker thread already running.") return self._map_worker_stop_event.clear() self._map_worker_thread = threading.Thread( target=self._map_render_worker_target, name="MapRenderWorker", daemon=True ) self._map_worker_thread.start() logger.info("MapRenderWorker thread started successfully.") def _map_render_worker_target(self): worker_initial_settle_delay_seconds = 0.1 thread_name = threading.current_thread().name logger.info( f"{thread_name}: Target loop initiated, starting initial settle delay ({worker_initial_settle_delay_seconds}s)..." ) if self._map_worker_stop_event.wait( timeout=worker_initial_settle_delay_seconds ): logger.info( f"{thread_name}: Received stop signal during initial settle. Terminating worker target." ) return logger.info( f"{thread_name}: Initial settle delay complete. Worker entering main request loop." ) while not self._map_worker_stop_event.is_set(): request_data = None request_id = -1 try: logger.debug( f"{thread_name}: Waiting for request from queue (timeout {MAP_WORKER_QUEUE_TIMEOUT_S}s)..." ) request_data = self._map_render_request_queue.get( timeout=MAP_WORKER_QUEUE_TIMEOUT_S ) request_type = request_data.get("type") request_id = request_data.get("request_id", -1) logger.info( f"{thread_name}: Dequeued request. Type: '{request_type}', ID: {request_id}" ) if request_type == RENDER_REQUEST_TYPE_SHUTDOWN: logger.info( f"{thread_name}: Received RENDER_REQUEST_TYPE_SHUTDOWN. Exiting loop." ) self._map_render_request_queue.task_done() break if request_type == RENDER_REQUEST_TYPE_MAP: logger.info( f"{thread_name}: Processing RENDER_REQUEST_TYPE_MAP for ID: {request_id}" ) center_lat = request_data.get("center_lat") center_lon = request_data.get("center_lon") zoom = request_data.get("zoom") canvas_w = request_data.get("canvas_width") canvas_h = request_data.get("canvas_height") if None in [center_lat, center_lon, zoom, canvas_w, canvas_h]: logger.error( f"{thread_name}: Missing critical parameters in request ID {request_id}. Aborting. Data: {request_data}" ) error_payload = { "request_id": request_id, "photo_image": None, "map_geo_bounds": None, "error": "Worker: Missing critical render parameters.", } self._map_render_result_queue.put(error_payload) self._map_render_request_queue.task_done() continue logger.debug( f"{thread_name}: Parameters for ID {request_id} - Center:({center_lat:.3f},{center_lon:.3f}), Z:{zoom}, Canvas:{canvas_w}x{canvas_h}" ) target_bbox = request_data.get("target_bbox") # flights_to_draw e tracks_to_draw vengono passati come copie flights_to_draw = request_data.get("flights", []) tracks_to_draw = request_data.get( "tracks", {} ) # Questa è una dict[str, deque] max_track_pts_from_req = request_data.get( "max_track_points", DEFAULT_MAX_TRACK_POINTS ) photo_image_result, actual_map_bounds, error_message = ( self._execute_render_pipeline( center_lat, center_lon, zoom, canvas_w, canvas_h, target_bbox, flights_to_draw, tracks_to_draw, max_track_pts_from_req, ) ) if self._map_worker_stop_event.is_set(): logger.info( f"{thread_name}: Stop event set after render pipeline for ID {request_id}. Discarding result." ) break result_payload = { "request_id": request_id, "photo_image": photo_image_result, "map_geo_bounds": actual_map_bounds, "error": error_message, } logger.debug( f"{thread_name}: Attempting to put result for ID {request_id} into queue (Queue size: {self._map_render_result_queue.qsize()})." ) self._map_render_result_queue.put(result_payload) logger.info( f"{thread_name}: Successfully put result for ID {request_id} into queue." ) else: logger.warning( f"{thread_name}: Received unknown request type '{request_type}' for ID {request_id}." ) self._map_render_request_queue.task_done() except queue.Empty: logger.debug( f"{thread_name}: Request queue empty, looping to check stop event." ) continue except Exception as e: logger.exception( f"{thread_name}: Unhandled exception in worker loop for request ID {request_id if request_data else 'N/A'}: {e}" ) if request_data and request_id != -1: error_payload_exc = { "request_id": request_id, "photo_image": None, "map_geo_bounds": None, "error": f"Worker loop unhandled exception: {type(e).__name__}", } try: self._map_render_result_queue.put_nowait(error_payload_exc) logger.info( f"{thread_name}: Reported unhandled exception for ID {request_id} to result queue." ) except queue.Full: logger.error( f"{thread_name}: Result queue full while trying to report worker unhandled exception." ) except Exception as e_put_err: logger.error( f"{thread_name}: Error putting unhandled exception report to result queue: {e_put_err}" ) if request_data: try: self._map_render_request_queue.task_done() except ValueError: pass time.sleep(0.5) logger.info(f"{thread_name}: Worker thread target loop finished.") def _start_gui_result_processing(self): if self._gui_after_id_result_processor: try: if self.canvas.winfo_exists(): self.canvas.after_cancel(self._gui_after_id_result_processor) except Exception as e_cancel: logger.warning( f"Error cancelling previous result processor: {e_cancel}" ) if self.canvas.winfo_exists(): self._gui_after_id_result_processor = self.canvas.after( GUI_RESULT_POLL_INTERVAL_MS, self._process_map_render_results ) logger.debug("GUI result processing scheduled.") else: logger.warning( "Canvas does not exist, cannot schedule GUI result processing." ) def _execute_render_pipeline( self, center_lat: float, center_lon: float, zoom_level: int, canvas_w: int, canvas_h: int, target_bbox_input: Optional[Dict[str, float]], current_flights_to_display: List[CanonicalFlightState], flight_tracks: Dict[ str, deque ], # flight_tracks è Dict[str, deque[Tuple[float,float,float]]] max_track_points_config: int, # Max track points dalla configurazione/GUI ) -> Tuple[ Optional[ImageTk.PhotoImage], Optional[Tuple[float, float, float, float]], Optional[str], ]: logger.debug( f"WorkerPipeline: Starting for Z{zoom_level}, Center ({center_lat:.4f},{center_lon:.4f}), Canvas {canvas_w}x{canvas_h}" ) if ( not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or Image is None or ImageDraw is None or ImageTk is None ): err_msg = "WorkerPipeline: Pillow/Mercantile/ImageTk dependencies missing." logger.error(err_msg) return None, None, err_msg if canvas_w <= 0 or canvas_h <= 0: err_msg = ( f"WorkerPipeline: Invalid canvas dimensions ({canvas_w}x{canvas_h})." ) logger.error(err_msg) return None, None, err_msg canvas_geo_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom( center_lat, center_lon, canvas_w, canvas_h, zoom_level, self.tile_manager.tile_size, ) if not canvas_geo_bbox: err_msg = "WorkerPipeline: Failed to calculate canvas geographic BBox." logger.error(err_msg) return None, None, err_msg logger.debug(f"WorkerPipeline: Calculated canvas_geo_bbox: {canvas_geo_bbox}") tile_xy_ranges = get_tile_ranges_for_bbox(canvas_geo_bbox, zoom_level) if not tile_xy_ranges: err_msg = f"WorkerPipeline: Failed to get tile ranges for {canvas_geo_bbox} at Z{zoom_level}." logger.error(err_msg) try: placeholder_img = Image.new( "RGB", (canvas_w, canvas_h), map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB, ) draw = ImageDraw.Draw(placeholder_img) map_drawing._draw_text_on_placeholder( draw, placeholder_img.size, err_msg.replace("WorkerPipeline: ", "") ) if ImageTk: return ImageTk.PhotoImage(placeholder_img), None, err_msg else: return ( None, None, "WorkerPipeline: ImageTk unavailable for placeholder.", ) except Exception as e_ph: err_msg_ph = f"WorkerPipeline: Tile range error AND placeholder creation failed: {e_ph}" logger.error(err_msg_ph) return None, None, err_msg_ph logger.debug(f"WorkerPipeline: Calculated tile_xy_ranges: {tile_xy_ranges}") stitched_map_pil = self.tile_manager.stitch_map_image( zoom_level, tile_xy_ranges[0], tile_xy_ranges[1] ) if not stitched_map_pil: err_msg = "WorkerPipeline: Failed to stitch map image from TileManager." logger.error(err_msg) return None, None, err_msg logger.debug( f"WorkerPipeline: Successfully stitched base map image {stitched_map_pil.size}." ) actual_stitched_map_geo_bounds = self.tile_manager._get_bounds_for_tile_range( zoom_level, tile_xy_ranges ) if not actual_stitched_map_geo_bounds: logger.warning( "WorkerPipeline: Could not determine actual stitched map geo bounds. Using canvas_geo_bbox as fallback." ) actual_stitched_map_geo_bounds = canvas_geo_bbox logger.debug( f"WorkerPipeline: Actual stitched map geo bounds: {actual_stitched_map_geo_bounds}" ) if stitched_map_pil.mode != "RGBA": image_to_draw_on = stitched_map_pil.convert("RGBA") else: image_to_draw_on = stitched_map_pil.copy() img_shape = image_to_draw_on.size # (width, height) draw = ImageDraw.Draw(image_to_draw_on) logger.debug( f"WorkerPipeline: Prepared image for drawing overlays, size {img_shape}." ) if target_bbox_input and _is_valid_bbox_dict(target_bbox_input): bbox_wesn = ( target_bbox_input["lon_min"], target_bbox_input["lat_min"], target_bbox_input["lon_max"], target_bbox_input["lat_max"], ) try: map_drawing.draw_area_bounding_box( # Usa la funzione da map_drawing image_to_draw_on, bbox_wesn, actual_stitched_map_geo_bounds, img_shape, color=map_constants.AREA_BOUNDARY_COLOR, thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX, ) logger.debug(f"WorkerPipeline: Drew target BBox.") except Exception as e_bbox_draw_pipe: logger.error( f"WorkerPipeline: Error drawing target BBox: {e_bbox_draw_pipe}", exc_info=False, ) # Disegna voli e le loro tracce if current_flights_to_display: font_size = map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE + ( zoom_level - map_constants.DEM_TILE_LABEL_BASE_ZOOM ) label_font = map_drawing._load_label_font( font_size ) # Usa la funzione da map_drawing flights_drawn_count = 0 for flight in current_flights_to_display: if flight.latitude is not None and flight.longitude is not None: pixel_coords_f = map_drawing._geo_to_pixel_on_unscaled_map( # Usa la funzione da map_drawing flight.latitude, flight.longitude, actual_stitched_map_geo_bounds, img_shape, ) if pixel_coords_f: try: current_flight_track_deque = flight_tracks.get( flight.icao24 ) # Prende la deque dal dict passato map_drawing._draw_single_flight( # Usa la funzione da map_drawing draw, pixel_coords_f, flight, label_font, track_deque=current_flight_track_deque, current_map_geo_bounds=actual_stitched_map_geo_bounds, current_stitched_map_pixel_shape=img_shape, # max_track_points_config e track_line_width sono ora letti globalmente in map_drawing ) flights_drawn_count += 1 except Exception as e_flight_draw_pipe: logger.error( f"WorkerPipeline: Error drawing flight {flight.icao24}: {e_flight_draw_pipe}", exc_info=False, ) logger.debug( f"WorkerPipeline: Drew {flights_drawn_count} flight markers and tracks." ) try: if ImageTk: final_photo_image = ImageTk.PhotoImage(image_to_draw_on) logger.debug(f"WorkerPipeline: Successfully created PhotoImage.") return final_photo_image, actual_stitched_map_geo_bounds, None else: return ( None, actual_stitched_map_geo_bounds, "WorkerPipeline: ImageTk module not available.", ) except Exception as e_photo_pipe: err_msg_photo = ( f"WorkerPipeline: Failed to create PhotoImage: {e_photo_pipe}" ) logger.error(err_msg_photo, exc_info=True) return None, actual_stitched_map_geo_bounds, err_msg_photo def _process_map_render_results(self): if not self.canvas.winfo_exists(): logger.info("Canvas destroyed, stopping map render result processing.") self._gui_after_id_result_processor = None return logger.debug( f"GUI ResultsProcessor: Checking result queue. Expected ID >= {self._expected_render_id_gui}. Queue size: {self._map_render_result_queue.qsize()}" ) processed_one = False try: while not self._map_render_result_queue.empty(): result_data = self._map_render_result_queue.get_nowait() processed_one = True request_id = result_data.get("request_id") photo_image = result_data.get("photo_image") rendered_map_bounds = result_data.get("map_geo_bounds") error_message = result_data.get("error") logger.info( f"GUI ResultsProcessor: Dequeued result for ReqID {request_id}. Error: '{error_message}', Img Valid: {photo_image is not None}, Expected GUI ID: {self._expected_render_id_gui}" ) if request_id < self._expected_render_id_gui: logger.warning( f"GUI ResultsProcessor: Discarding STALE map render result ID {request_id} (expected >= {self._expected_render_id_gui})." ) self._map_render_result_queue.task_done() continue if error_message: logger.error( f"GUI ResultsProcessor: Received error from MapRenderWorker (ReqID {request_id}): {error_message}" ) self._display_placeholder_text( f"Map Error (Worker):\n{error_message[:100]}" ) elif ( photo_image and ImageTk and isinstance(photo_image, ImageTk.PhotoImage) ): logger.info( f"GUI ResultsProcessor: Applying new map image from worker for ReqID {request_id}." ) self._clear_canvas_display_elements() self._map_photo_image = photo_image self._canvas_image_id = self.canvas.create_image( 0, 0, anchor=tk.NW, image=self._map_photo_image ) self._current_map_geo_bounds_gui = rendered_map_bounds logger.debug( f"GUI ResultsProcessor: Canvas updated with image for ReqID {request_id}. New map bounds: {self._current_map_geo_bounds_gui}" ) else: logger.warning( f"GUI ResultsProcessor: Received invalid/empty result from worker for ReqID {request_id}. No PhotoImage. Error was: '{error_message}'" ) self._display_placeholder_text( f"Map Update Failed\n(No Image Data for ReqID {request_id})" ) self._map_render_result_queue.task_done() if self.app_controller and hasattr( self.app_controller, "update_general_map_info" ): logger.debug( f"GUI ResultsProcessor: Requesting controller to update general map info after processing ReqID {request_id}." ) self.app_controller.update_general_map_info() except queue.Empty: logger.debug("GUI ResultsProcessor: Result queue is empty.") pass except Exception as e: logger.exception( f"GUI ResultsProcessor: Error processing map render results: {e}" ) if self.canvas.winfo_exists(): self._gui_after_id_result_processor = self.canvas.after( GUI_RESULT_POLL_INTERVAL_MS, self._process_map_render_results ) logger.debug( f"GUI ResultsProcessor: Rescheduled itself. Processed one this cycle: {processed_one}" ) else: logger.info("GUI ResultsProcessor: Canvas gone, not rescheduling.") def _request_map_render( self, center_lat: float, center_lon: float, zoom_level: int, ensure_bbox_is_covered_dict: Optional[Dict[str, float]] = None, ): logger.debug( f"GUI _request_map_render: Input center=({center_lat:.4f},{center_lon:.4f}), zoom={zoom_level}" ) if ( self._map_worker_stop_event.is_set() or not self._map_worker_thread or not self._map_worker_thread.is_alive() ): logger.warning("Map worker not running. Cannot queue render request.") self._display_placeholder_text("Map Worker Offline") return with self._map_data_lock: self._last_render_request_id += 1 current_request_id = self._last_render_request_id self._expected_render_id_gui = current_request_id logger.info( f"GUI _request_map_render: New request ID {current_request_id}. Expected GUI ID set to {self._expected_render_id_gui}." ) # Copia i dati condivisi sotto lock flights_copy = [] try: flights_copy = copy.deepcopy(self._current_flights_to_display_gui) except Exception as e_copy_f: logger.error( f"Error deepcopying flights for render request: {e_copy_f}" ) tracks_copy = {} try: tracks_copy = copy.deepcopy( self.flight_tracks_gui ) # Deepcopy delle code except Exception as e_copy_t: logger.error(f"Error deepcopying tracks for render request: {e_copy_t}") target_bbox_to_send = None if ensure_bbox_is_covered_dict: try: target_bbox_to_send = copy.deepcopy(ensure_bbox_is_covered_dict) except Exception as e_copy_b_ensure: logger.error( f"Error deepcopying ensure_bbox for render request: {e_copy_b_ensure}" ) elif self._target_bbox_input_gui: try: target_bbox_to_send = copy.deepcopy(self._target_bbox_input_gui) except Exception as e_copy_b_target: logger.error( f"Error deepcopying target_bbox_input for render request: {e_copy_b_target}" ) request_payload = { "type": RENDER_REQUEST_TYPE_MAP, "request_id": current_request_id, "center_lat": center_lat, "center_lon": center_lon, "zoom": zoom_level, "canvas_width": self.canvas_width, "canvas_height": self.canvas_height, "target_bbox": target_bbox_to_send, "flights": flights_copy, "tracks": tracks_copy, "max_track_points": self.max_track_points, } logger.debug( f"GUI _request_map_render: Assembled payload for ReqID {current_request_id}: Center=({request_payload['center_lat']:.3f},{request_payload['center_lon']:.3f}), Z={request_payload['zoom']}, Canvas={request_payload['canvas_width']}x{request_payload['canvas_height']}, TargetBBox provided: {request_payload['target_bbox'] is not None}, NumFlights: {len(flights_copy)}, NumTracks: {len(tracks_copy)}" ) try: self._clear_canvas_display_elements() loading_text = f"Loading Map...\nZ{zoom_level} @ ({center_lat:.2f}, {center_lon:.2f})\nReqID: {current_request_id}" logger.debug( f"GUI _request_map_render: Displaying placeholder: '{loading_text}'" ) self._display_placeholder_text(loading_text) logger.debug( f"GUI _request_map_render: Attempting to put ReqID {current_request_id} in request queue (current qsize: {self._map_render_request_queue.qsize()})." ) self._map_render_request_queue.put_nowait(request_payload) logger.info( f"GUI _request_map_render: Successfully queued map render request ID {current_request_id} (Z{zoom_level})." ) self._current_center_lat_gui = center_lat self._current_center_lon_gui = center_lon self._current_zoom_gui = zoom_level logger.debug( f"GUI _request_map_render: Updated _gui state vars: center=({self._current_center_lat_gui},{self._current_center_lon_gui}), zoom={self._current_zoom_gui}" ) except queue.Full: logger.warning( f"Map render request queue FULL. Request ID {current_request_id} was DROPPED." ) with self._map_data_lock: if ( self._expected_render_id_gui == current_request_id ): # Solo se era l'ultimo atteso # Se la coda è piena, non possiamo garantire quale richiesta verrà elaborata. # Non decrementare _expected_render_id_gui qui, perché la GUI si aspetta # ancora questa richiesta. Il worker scarterà le richieste precedenti se ne arriva una nuova. # Il problema è solo se la *coda* è piena. pass # Non modificare _expected_render_id_gui, potrebbe essere ancora valido. self._display_placeholder_text( "Map Busy / Request Queue Full.\nPlease Try Action Again." ) except Exception as e: logger.exception( f"GUI _request_map_render: Error queuing map render request ID {current_request_id}: {e}" ) self._display_placeholder_text( f"Error Queuing Map Request:\n{type(e).__name__}" ) 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 logger.debug("MCM Event bindings set up.") def _on_canvas_resize(self, event: tk.Event): new_width, new_height = event.width, event.height logger.debug( f"MCM _on_canvas_resize: Event triggered with new_width={new_width}, new_height={new_height}. Current canvas_width={self.canvas_width}, canvas_height={self.canvas_height}" ) if ( new_width > 1 and new_height > 1 and (self.canvas_width != new_width or self.canvas_height != new_height) ): logger.info( f"MCM _on_canvas_resize: Canvas dimensions changed from {self.canvas_width}x{self.canvas_height} to {new_width}x{new_height}. Debouncing redraw." ) if self._resize_debounce_job_id: try: if self.canvas.winfo_exists(): self.canvas.after_cancel(self._resize_debounce_job_id) logger.debug( f"MCM _on_canvas_resize: Cancelled previous debounce job: {self._resize_debounce_job_id}" ) except Exception as e_cancel_resize: logger.warning( f"MCM _on_canvas_resize: Error cancelling previous resize job: {e_cancel_resize}" ) if self.canvas.winfo_exists(): self._resize_debounce_job_id = self.canvas.after( RESIZE_DEBOUNCE_DELAY_MS, self._perform_resize_redraw, new_width, new_height, ) logger.debug( f"MCM _on_canvas_resize: Scheduled new debounce job: {self._resize_debounce_job_id} for {new_width}x{new_height}" ) else: logger.warning( "MCM _on_canvas_resize: Canvas does not exist, cannot schedule resize redraw." ) else: logger.debug( f"MCM _on_canvas_resize: Ignoring resize event (no change or invalid new dims: {new_width}x{new_height})." ) def _perform_resize_redraw(self, width: int, height: int): self._resize_debounce_job_id = None if not self.canvas.winfo_exists(): logger.warning( "MCM _perform_resize_redraw: Canvas does not exist. Aborting." ) return logger.info( f"MCM _perform_resize_redraw: Executing debounced resize to {width}x{height}. Requesting new render." ) self.canvas_width, self.canvas_height = width, height logger.debug( f"MCM _perform_resize_redraw: Updated canvas_width={self.canvas_width}, canvas_height={self.canvas_height}" ) if ( self._current_center_lat_gui is not None and self._current_center_lon_gui is not None and self._current_zoom_gui is not None ): logger.debug( f"MCM _perform_resize_redraw: Requesting render with current GUI state: center=({self._current_center_lat_gui},{self._current_center_lon_gui}), zoom={self._current_zoom_gui}" ) self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, ensure_bbox_is_covered_dict=self._target_bbox_input_gui, ) else: logger.warning( "MCM _perform_resize_redraw: Cannot redraw on resize - current map center/zoom not set in GUI state." ) self._display_placeholder_text("Map State Error\n(Cannot redraw on resize)") def set_max_track_points(self, length: int): new_length = max(2, length) logger.debug( f"MCM set_max_track_points: Requested length {length}, effective new_length {new_length}. Current: {self.max_track_points}" ) if self.max_track_points != new_length: logger.info( f"MapCanvasManager: Max track points updating from {self.max_track_points} to {new_length}" ) with self._map_data_lock: # Proteggere flight_tracks_gui self.max_track_points = ( new_length # Aggiorna l'attributo usato dal worker ) logger.debug( f"MCM set_max_track_points: Trimming existing GUI tracks (count: {len(self.flight_tracks_gui)})." ) for icao in list( self.flight_tracks_gui.keys() ): # list() per evitare RuntimeError se si modifica il dict track_deque = self.flight_tracks_gui.get(icao) if track_deque: # Crea una nuova deque con il maxlen corretto e gli ultimi N elementi # Questo è più sicuro che modificare la deque esistente se il maxlen è cambiato new_deque = deque( maxlen=self.max_track_points + 5 ) # Usa il nuovo max_track_points per la nuova deque # Prendi gli ultimi N elementi dalla vecchia deque, fino al nuovo self.max_track_points num_to_keep = min(len(track_deque), self.max_track_points) for i in range(num_to_keep): new_deque.append( track_deque[len(track_deque) - num_to_keep + i] ) if not new_deque: # Se dopo il trim è vuota if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao] logger.debug( f"Removed empty track for {icao} after trimming due to max_track_points change." ) else: self.flight_tracks_gui[icao] = new_deque logger.debug( f"Replaced track for {icao} with new deque of length {len(new_deque)}." ) if ( self._current_center_lat_gui is not None and self._current_center_lon_gui is not None and self._current_zoom_gui is not None ): logger.info( "MCM set_max_track_points: Requesting map re-render due to track length change." ) self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, ) else: logger.warning( "MCM set_max_track_points: Cannot request re-render as current view state is not set." ) else: logger.debug( f"MapCanvasManager: Max track points already set to {new_length}. No change, no re-render requested by this method." ) def set_target_bbox(self, new_bbox_dict: Dict[str, float]): logger.info( f"MCM set_target_bbox (GUI): Received new target BBox: {new_bbox_dict}" ) if new_bbox_dict and _is_valid_bbox_dict(new_bbox_dict): with self._map_data_lock: # Protegge _target_bbox_input_gui self._target_bbox_input_gui = new_bbox_dict.copy() logger.debug( f"MCM set_target_bbox: _target_bbox_input_gui updated. Requesting render for this new bbox." ) self._request_map_render_for_bbox( self._target_bbox_input_gui, 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_gui) else: logger.warning( f"MCM set_target_bbox: Invalid/empty new_bbox_dict: {new_bbox_dict}. Clearing _target_bbox_input_gui." ) with self._map_data_lock: self._target_bbox_input_gui = None if ( self._current_center_lat_gui is not None and self._current_center_lon_gui is not None and self._current_zoom_gui is not None ): logger.info( f"MCM set_target_bbox: Target BBox cleared. Re-rendering at current view: center=({self._current_center_lat_gui},{self._current_center_lon_gui}), zoom={self._current_zoom_gui}" ) self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, ) else: logger.warning( "MCM set_target_bbox: Target BBox cleared, but no current view to re-render. Clearing map display." ) self.clear_map_display() # Mostrerà placeholder if self.app_controller and hasattr( self.app_controller, "update_bbox_gui_fields" ): self.app_controller.update_bbox_gui_fields( {} ) # Invia dict vuoto per resettare i campi GUI def _request_map_render_for_bbox( self, target_bbox_dict: Dict[str, float], preserve_current_zoom_if_possible: bool = False, ): logger.debug( f"MCM _request_map_render_for_bbox: target_bbox={target_bbox_dict}, preserve_zoom={preserve_current_zoom_if_possible}" ) if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict): logger.warning( "_request_map_render_for_bbox called with invalid/no target BBox. Aborting." ) 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 ): # Gestisce BBox che attraversano l'antimeridiano (per il centro) view_center_lon = (lon_min + (lon_max + 360.0)) / 2.0 if view_center_lon >= 180.0: view_center_lon -= 360.0 logger.debug( f"MCM _request_map_render_for_bbox: Calculated BBox center: ({view_center_lat:.4f}, {view_center_lon:.4f})" ) zoom_to_use = self._current_zoom_gui # Default alla zoom corrente logger.debug( f"MCM _request_map_render_for_bbox: Initial zoom_to_use (from _current_zoom_gui): {zoom_to_use}" ) if not preserve_current_zoom_if_possible or zoom_to_use is None: logger.debug( f"MCM _request_map_render_for_bbox: Calculating new zoom (preserve_current_zoom_if_possible={preserve_current_zoom_if_possible}, zoom_to_use_is_None={zoom_to_use is None})" ) patch_width_km, patch_height_km = calculate_geographic_bbox_size_km( (lon_min, lat_min, lon_max, lat_max) # W,S,E,N ) or (None, None) logger.debug( f"MCM _request_map_render_for_bbox: BBox size: {patch_width_km}km x {patch_height_km}km" ) 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, ) logger.debug( f"MCM _request_map_render_for_bbox: Calculated zoom_w={zoom_w}, zoom_h={zoom_h}" ) if zoom_w is not None and zoom_h is not None: new_calc_zoom = min(zoom_w, zoom_h) elif zoom_w is not None: new_calc_zoom = zoom_w elif zoom_h is not None: new_calc_zoom = zoom_h else: new_calc_zoom = map_constants.DEFAULT_INITIAL_ZOOM logger.warning( f"MCM _request_map_render_for_bbox: Both zoom_w and zoom_h are None. Using default zoom {new_calc_zoom}." ) zoom_to_use = new_calc_zoom logger.info( f"MCM _request_map_render_for_bbox: Calculated new zoom_to_use: {zoom_to_use}" ) else: logger.warning( "MCM _request_map_render_for_bbox: Could not calculate BBox dimensions or canvas size invalid. Using default zoom." ) zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: logger.debug( f"MCM _request_map_render_for_bbox: Preserving current zoom: {zoom_to_use}" ) max_zoom_svc = ( 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_svc)) logger.debug( f"MCM _request_map_render_for_bbox: Final zoom_to_use after clamping: {zoom_to_use}" ) self._request_map_render( view_center_lat, view_center_lon, zoom_to_use, ensure_bbox_is_covered_dict=target_bbox_dict, ) def _clear_canvas_display_elements(self): logger.debug( "MCM _clear_canvas_display_elements: Clearing canvas image and placeholder text." ) if self._canvas_image_id is not None and self.canvas.winfo_exists(): try: self.canvas.delete(self._canvas_image_id) except Exception as e_del_img: logger.warning( f"Error deleting canvas image ID {self._canvas_image_id}: {e_del_img}" ) finally: self._canvas_image_id = None if self._map_photo_image is not None: self._map_photo_image = None # Rimuove riferimento all'immagine if self._placeholder_text_id is not None and self.canvas.winfo_exists(): try: self.canvas.delete(self._placeholder_text_id) except Exception as e_del_text: logger.warning( f"Error deleting placeholder text ID {self._placeholder_text_id}: {e_del_text}" ) finally: self._placeholder_text_id = None logger.debug("MCM _clear_canvas_display_elements: Done.") def _display_placeholder_text(self, text: str): logger.debug(f"MCM _display_placeholder_text: Displaying '{text[:50]}...'") if not self.canvas.winfo_exists(): logger.warning("MCM _display_placeholder_text: Canvas does not exist.") return self._clear_canvas_display_elements() # Assicura che il canvas sia pulito prima di disegnare il testo try: canvas_w_disp, canvas_h_disp = ( self.canvas.winfo_width(), self.canvas.winfo_height(), ) if canvas_w_disp <= 1: canvas_w_disp = self.canvas_width # Fallback if canvas_h_disp <= 1: canvas_h_disp = self.canvas_height # Fallback logger.debug( f"MCM _display_placeholder_text: Using canvas_dims {canvas_w_disp}x{canvas_h_disp}" ) if canvas_w_disp > 1 and canvas_h_disp > 1: bg_color_to_use = getattr( map_constants, "DEFAULT_PLACEHOLDER_COLOR_RGB_TK", "gray90" ) self.canvas.configure( bg=bg_color_to_use ) # Imposta il colore di sfondo del canvas self._placeholder_text_id = self.canvas.create_text( canvas_w_disp / 2, canvas_h_disp / 2, text=text, fill="gray10", font=("Arial", 11, "normal"), justify=tk.CENTER, width=max(100, canvas_w_disp - 40), ) logger.debug( f"MCM _display_placeholder_text: Placeholder text ID {self._placeholder_text_id} created." ) else: logger.warning( f"MCM _display_placeholder_text: Cannot draw placeholder text, canvas dims invalid ({canvas_w_disp}x{canvas_h_disp})." ) except tk.TclError as e_tcl_placeholder: logger.warning( f"MCM _display_placeholder_text: TclError displaying placeholder text (canvas might be gone): {e_tcl_placeholder}" ) except Exception as e_placeholder: logger.error( f"MCM _display_placeholder_text: Unexpected error: {e_placeholder}", exc_info=True, ) def clear_map_display(self): logger.info( "MCM clear_map_display: Clearing all map content and resetting view state." ) self._clear_canvas_display_elements() self._display_placeholder_text("Map Cleared / Awaiting Action") with self._map_data_lock: self._current_flights_to_display_gui = [] self.flight_tracks_gui.clear() self._current_map_geo_bounds_gui = None # L'immagine della mappa è sparita, quindi i suoi bounds non sono più validi logger.debug( "MCM clear_map_display: Internal data cleared (flights, tracks, map_bounds)." ) # Forza un re-render della mappa base (senza voli/tracce) se la vista era impostata if ( self._current_center_lat_gui is not None and self._current_center_lon_gui is not None and self._current_zoom_gui is not None ): logger.info( "MCM clear_map_display: Requesting re-render of clean base map." ) self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, ensure_bbox_is_covered_dict=self._target_bbox_input_gui, # Mantiene il BBox di monitoraggio se era impostato ) else: # Se non c'è uno stato di vista precedente, il placeholder è sufficiente. # L'utente dovrà interagire (es. impostare un BBox) per vedere una mappa. logger.warning( "MCM clear_map_display: No current view state to request clean render, placeholder remains." ) # Assicuriamoci che le info sulla mappa vengano aggiornate per riflettere lo stato "pulito" 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 logger.debug(f"MCM _on_left_button_press: CanvasX={event.x}, CanvasY={event.y}") 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(): return logger.debug(f"MCM Left Button Release: CanvasX={event.x}, CanvasY={event.y}") # Usa module_logger if not self._is_left_button_pressed: # Verifica se il bottone era stato premuto logger.debug("MCM Left Button Release: Button was not previously pressed. Ignoring.") self._drag_start_x_canvas, self._drag_start_y_canvas = None, None # Resetta per sicurezza return self._is_left_button_pressed = False # Resetta lo stato del bottone clicked_flight_icao: Optional[str] = None # Per memorizzare l'ICAO del volo cliccato if self._drag_start_x_canvas is not None and self._drag_start_y_canvas is not None: drag_threshold_px = 10 # Soglia per distinguere click da drag dx = abs(event.x - self._drag_start_x_canvas) dy = abs(event.y - self._drag_start_y_canvas) is_click = (dx < drag_threshold_px and dy < drag_threshold_px) logger.debug(f"MCM Left Button Release: dx={dx}, dy={dy}. Is click: {is_click}") if is_click: logger.debug("MCM Left Button Release: Detected as a map click.") if self._current_map_geo_bounds_gui is not None and self._map_photo_image is not None: try: map_pixel_shape = (self._map_photo_image.width(), self._map_photo_image.height()) clicked_lon, clicked_lat = map_utils._pixel_to_geo( # Usa map_drawing._pixel_to_geo event.x, event.y, self._current_map_geo_bounds_gui, map_pixel_shape ) if clicked_lon is not None and clicked_lat is not None: logger.info(f"Map Left-Clicked at Geo ({clicked_lat:.5f}, {clicked_lon:.5f}) from Canvas ({event.x},{event.y})") if self.app_controller and hasattr(self.app_controller, "on_map_left_click"): self.app_controller.on_map_left_click(clicked_lat, clicked_lon, event.x_root, event.y_root) # --- Logica per identificare il volo cliccato --- min_dist_sq_to_flight = float('inf') # Soglia di click in pixel per selezionare un aereo, al quadrato per evitare sqrt flight_click_radius_px_sq = (15 * 15) with self._map_data_lock: # Accedi in modo sicuro alla lista dei voli flights_on_map = self._current_flights_to_display_gui if flights_on_map: # Solo se ci sono voli da controllare for flight in flights_on_map: if flight.latitude is not None and flight.longitude is not None: # Converte la posizione del volo in pixel sulla mappa attuale flight_px_coords = map_drawing._geo_to_pixel_on_unscaled_map( flight.latitude, flight.longitude, self._current_map_geo_bounds_gui, # Bounds della mappa visualizzata map_pixel_shape # Dimensioni in pixel della mappa visualizzata ) if flight_px_coords: # Calcola la distanza al quadrato tra il click e l'aereo dist_sq = (event.x - flight_px_coords[0])**2 + (event.y - flight_px_coords[1])**2 if dist_sq < flight_click_radius_px_sq and dist_sq < min_dist_sq_to_flight: min_dist_sq_to_flight = dist_sq clicked_flight_icao = flight.icao24 if clicked_flight_icao: logger.info(f"Flight selected by click: {clicked_flight_icao}") if self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info(clicked_flight_icao) else: logger.info("No specific flight selected by click (click was on map but not near an aircraft). Clearing details.") if self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") # Invia ICAO vuoto per pulire il pannello else: # clicked_lon/lat is None logger.warning(f"Failed to convert left click pixel ({event.x},{event.y}) to geo coordinates.") if self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") # Pulisci dettagli se il click non è valido except Exception as e_click_processing: logger.error(f"Error during left click processing (geo conversion or flight selection): {e_click_processing}", exc_info=True) if self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") # Pulisci in caso di errore else: # Manca il contesto della mappa (_current_map_geo_bounds_gui o _map_photo_image) logger.warning("Map context missing for left click geo conversion or flight selection.") if self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") else: # Era un drag logger.debug("MCM Left Button Release: Detected as a drag. Panning logic (if any) would go here.") # Se implementi il drag-to-pan, qui non faresti nulla per la selezione dei voli. # Resetta le coordinate di inizio drag 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 logger.debug(f"MCM _on_right_click: CanvasX={event.x}, CanvasY={event.y}") if self._current_map_geo_bounds_gui is None or self._map_photo_image is None: logger.warning("Map context missing for right click geo conversion.") return try: map_pixel_shape = ( self._map_photo_image.width(), self._map_photo_image.height(), ) geo_lon, geo_lat = _pixel_to_geo( event.x, event.y, self._current_map_geo_bounds_gui, 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" ): # Modificato in on_map_right_click self.app_controller.on_map_right_click( geo_lat, geo_lon, event.x_root, event.y_root ) else: logger.warning( f"Failed to convert right click pixel ({event.x},{event.y}) to geo." ) except Exception as e_rclick_convert: logger.error( f"Error during right click geo conversion: {e_rclick_convert}", exc_info=True, ) def update_flights_on_map(self, flight_states: List[CanonicalFlightState]): logger.info( f"MCM update_flights_on_map (GUI): Received {len(flight_states)} flight states." ) with self._map_data_lock: # Protegge _current_flights_to_display_gui e flight_tracks_gui self._current_flights_to_display_gui = ( flight_states # Sostituisce la lista dei voli ) if not flight_states: # Monitoraggio fermato o nessun volo logger.info( "MCM update_flights_on_map: Received empty flight_states. Clearing all flight tracks." ) self.flight_tracks_gui.clear() else: # Ci sono voli, aggiorna le tracce 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_gui: # Usa self.max_track_points (che è letto da config) per il maxlen self.flight_tracks_gui[state.icao24] = deque( maxlen=self.max_track_points + 5 ) # +5 per buffer self.flight_tracks_gui[state.icao24].append( (state.latitude, state.longitude, state.timestamp) ) tracks_to_remove_gui = [] for icao, track_deque in self.flight_tracks_gui.items(): if ( not track_deque ): # Rimuovi se per qualche motivo la deque è vuota tracks_to_remove_gui.append(icao) continue if ( icao not in active_icao_this_update ): # Volo non più attivo nell'update corrente last_point_time = track_deque[-1][2] if current_time - last_point_time > self.max_track_age_seconds: tracks_to_remove_gui.append( icao ) # Rimuovi se anche troppo vecchia if tracks_to_remove_gui: logger.debug( f"MCM update_flights_on_map: Removing {len(tracks_to_remove_gui)} old/inactive GUI tracks." ) for icao in tracks_to_remove_gui: if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao] logger.debug( f"MCM update_flights_on_map: Updated _current_flights_to_display_gui with {len(self._current_flights_to_display_gui)} states. Total tracks: {len(self.flight_tracks_gui)}." ) # Richiedi un re-render della mappa con i nuovi dati dei voli/tracce if ( self._current_center_lat_gui is not None and self._current_center_lon_gui is not None and self._current_zoom_gui is not None ): logger.info( "MCM update_flights_on_map: Requesting map re-render due to flight update." ) self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, ) else: logger.warning( "MCM update_flights_on_map: Cannot request map render for flight update - current map view state not set." ) def get_current_map_info(self) -> Dict[str, Any]: logger.debug("MCM get_current_map_info (GUI) called.") map_size_km_w, map_size_km_h = None, None if ( PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj and self._current_map_geo_bounds_gui ): try: size_km_tuple = calculate_geographic_bbox_size_km( self._current_map_geo_bounds_gui ) if size_km_tuple: map_size_km_w, map_size_km_h = size_km_tuple except Exception as e_map_size_info: logger.warning( f"Error calculating current map geo size for info panel: {e_map_size_info}", exc_info=False, ) with self._map_data_lock: # Accedi a dati condivisi in modo sicuro flight_count = len(self._current_flights_to_display_gui) target_bbox_copy = ( copy.deepcopy(self._target_bbox_input_gui) if self._target_bbox_input_gui else None ) info = { "center_lat": self._current_center_lat_gui, "center_lon": self._current_center_lon_gui, "zoom": self._current_zoom_gui, "map_geo_bounds": self._current_map_geo_bounds_gui, "target_bbox_input": target_bbox_copy, "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": flight_count, } logger.debug( f"MCM get_current_map_info returning: Flights={info['flight_count']}, Zoom={info['zoom']}, Center=({info['center_lat']},{info['center_lon']})" ) return info def show_map_context_menu_from_gui( self, latitude: float, longitude: float, screen_x: int, screen_y: int ): logger.info( f"MCM show_map_context_menu_from_gui: Lat {latitude:.4f}, Lon {longitude:.4f}" ) if not self.canvas.winfo_exists(): logger.warning("MCM show_map_context_menu_from_gui: Canvas does not exist.") return root_widget = self.canvas.winfo_toplevel() # Prendi la finestra principale try: context_menu = tk.Menu(root_widget, tearoff=0) decimals = getattr(map_constants, "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: # Verifica se il controller è impostato 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" # Nome della costante nel controller area_km = getattr( self.app_controller, area_km_cfg_name, 50.0 ) # Usa getattr per fallback 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) logger.debug("MCM show_map_context_menu_from_gui: Context menu popped up.") except tk.TclError as e_menu_tcl_ctx: logger.warning( f"TclError showing MapCanvasManager context menu: {e_menu_tcl_ctx}." ) except Exception as e_menu_ctx: logger.error( f"Error creating/showing MapCanvasManager context menu: {e_menu_ctx}", exc_info=True, ) def recenter_map_at_coords(self, lat: float, lon: float): logger.info( f"MCM recenter_map_at_coords (GUI): Request to recenter map @ Geo ({lat:.4f}, {lon:.4f})" ) if self._current_zoom_gui is not None and self.canvas.winfo_exists(): self._current_center_lat_gui = lat self._current_center_lon_gui = lon logger.debug( f"MCM recenter_map_at_coords: Updated GUI center to ({lat:.4f},{lon:.4f}). Requesting render." ) self._request_map_render(lat, lon, self._current_zoom_gui) else: logger.warning( "MCM recenter_map_at_coords: Cannot recenter map - missing current zoom or canvas not available." ) def set_bbox_around_coords( self, center_lat: float, center_lon: float, area_size_km: float ): logger.info( f"MCM set_bbox_around_coords (GUI): 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( "MCM set_bbox_around_coords: Cannot set BBox - 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 ) # W,S,E,N 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): logger.debug( f"MCM set_bbox_around_coords: Calculated valid BBox: {bbox_dict}. Calling set_target_bbox." ) self.set_target_bbox( bbox_dict ) # Questo chiamerà _request_map_render_for_bbox else: logger.error( f"MCM set_bbox_around_coords: Calculated BBox 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"MCM set_bbox_around_coords: 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_set_bbox_calc: logger.exception( f"MCM set_bbox_around_coords: Unexpected error calculating BBox: {e_set_bbox_calc}" ) 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_set_bbox_calc}", ) def zoom_in_at_center(self): logger.debug( f"MCM zoom_in_at_center (GUI) called. Current zoom: {self._current_zoom_gui}" ) if ( self._current_zoom_gui is None or self._current_center_lat_gui is None or self._current_center_lon_gui is None ): logger.warning( "MCM zoom_in_at_center: Cannot zoom in - current map state (zoom/center) is not defined." ) return if not self.canvas.winfo_exists(): logger.warning( "MCM zoom_in_at_center: Cannot zoom in - canvas not available." ) return max_zoom_svc = ( self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK ) new_zoom = min(self._current_zoom_gui + 1, max_zoom_svc) logger.debug( f"MCM zoom_in_at_center: new_zoom calculated as {new_zoom} (max_svc_zoom={max_zoom_svc})" ) if new_zoom != self._current_zoom_gui: logger.info( f"MCM zoom_in_at_center: Zooming in from {self._current_zoom_gui} to {new_zoom}." ) self._current_zoom_gui = new_zoom self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, new_zoom ) else: logger.debug( f"MCM zoom_in_at_center: Already at max zoom ({self._current_zoom_gui}). Cannot zoom in further." ) def zoom_out_at_center(self): logger.debug( f"MCM zoom_out_at_center (GUI) called. Current zoom: {self._current_zoom_gui}" ) if ( self._current_zoom_gui is None or self._current_center_lat_gui is None or self._current_center_lon_gui is None ): logger.warning( "MCM zoom_out_at_center: Cannot zoom out - current map state (zoom/center) is not defined." ) return if not self.canvas.winfo_exists(): logger.warning( "MCM zoom_out_at_center: Cannot zoom out - canvas not available." ) return new_zoom = max(map_constants.MIN_ZOOM_LEVEL, self._current_zoom_gui - 1) logger.debug( f"MCM zoom_out_at_center: new_zoom calculated as {new_zoom} (min_level={map_constants.MIN_ZOOM_LEVEL})" ) if new_zoom != self._current_zoom_gui: logger.info( f"MCM zoom_out_at_center: Zooming out from {self._current_zoom_gui} to {new_zoom}." ) self._current_zoom_gui = new_zoom self._request_map_render( self._current_center_lat_gui, self._current_center_lon_gui, new_zoom ) else: logger.debug( f"MCM zoom_out_at_center: Already at min zoom ({self._current_zoom_gui}). Cannot zoom out further." ) def pan_map_fixed_step( self, direction: str, step_fraction: float = PAN_STEP_FRACTION ): logger.debug( f"MCM pan_map_fixed_step (GUI) called. Direction: {direction}, Current center: ({self._current_center_lat_gui},{self._current_center_lon_gui}), Zoom: {self._current_zoom_gui}" ) if ( self._current_center_lat_gui is None or self._current_center_lon_gui is None or self._current_zoom_gui is None ): logger.warning( "MCM pan_map_fixed_step: Cannot pan map - current map state (center/zoom) not fully defined." ) return if ( not self.canvas.winfo_exists() or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None ): logger.warning( "MCM pan_map_fixed_step: Cannot pan map - canvas or PyProj library 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) logger.debug( f"MCM pan_map_fixed_step: Pan step pixels W={pan_step_px_w}, H={pan_step_px_h}" ) if direction == "left": delta_x_pixels = ( -pan_step_px_w ) # Muove la mappa a sinistra, quindi il centro si sposta a destra (+lon) elif direction == "right": delta_x_pixels = pan_step_px_w # Muove la mappa a destra, centro si sposta a sinistra (-lon) elif direction == "up": delta_y_pixels = ( -pan_step_px_h ) # Muove mappa su, centro si sposta giù (-lat) elif direction == "down": delta_y_pixels = ( pan_step_px_h # Muove mappa giù, centro si sposta su (+lat) ) else: logger.warning( f"MCM pan_map_fixed_step: Unknown pan direction: {direction}" ) return logger.debug( f"MCM pan_map_fixed_step: Pixel deltas dx={delta_x_pixels}, dy={delta_y_pixels} (relative to current center)" ) res_m_px = calculate_meters_per_pixel( self._current_center_lat_gui, self._current_zoom_gui, self.tile_manager.tile_size, ) if res_m_px is None or res_m_px <= 1e-9: logger.error( "MCM pan_map_fixed_step: Could not calculate valid resolution for panning. Cannot pan." ) return logger.debug( f"MCM pan_map_fixed_step: Resolution for panning: {res_m_px:.2f} m/px" ) delta_meters_x = delta_x_pixels * res_m_px # Delta in metri verso Est delta_meters_y = ( -delta_y_pixels * res_m_px ) # Delta in metri verso Nord (NB: -dy perché pixel Y aumenta verso il basso) logger.debug( f"MCM pan_map_fixed_step: Meter deltas dMx={delta_meters_x:.2f} (East), dMy={delta_meters_y:.2f} (North)" ) geod = pyproj.Geod(ellps="WGS84") current_calc_lon, current_calc_lat = ( self._current_center_lon_gui, self._current_center_lat_gui, ) # Sposta prima in longitudine (Est/Ovest) if abs(delta_meters_x) > 1e-9: azimuth_lon = 90.0 if delta_meters_x > 0 else 270.0 # 90 Est, 270 Ovest # pyproj.Geod.fwd aspetta la distanza positiva new_lon, _, _ = geod.fwd( current_calc_lon, current_calc_lat, azimuth_lon, abs(delta_meters_x) ) current_calc_lon = new_lon logger.debug( f"MCM pan_map_fixed_step: After lon shift (az={azimuth_lon}, dist={abs(delta_meters_x):.1f}m), new lon={current_calc_lon:.4f}" ) # Poi sposta in latitudine (Nord/Sud) partendo dalla nuova longitudine if abs(delta_meters_y) > 1e-9: azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0 # 0 Nord, 180 Sud _, new_lat, _ = geod.fwd( current_calc_lon, current_calc_lat, azimuth_lat, abs(delta_meters_y) ) current_calc_lat = new_lat logger.debug( f"MCM pan_map_fixed_step: After lat shift (az={azimuth_lat}, dist={abs(delta_meters_y):.1f}m), new lat={current_calc_lat:.4f}" ) MAX_MERCATOR_LAT = 85.05112878 # Limite per la proiezione Web Mercator final_new_center_lat = max( -MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, current_calc_lat) ) # Normalizza longitudine a [-180, 180] final_new_center_lon = (current_calc_lon + 180) % 360 - 180 if ( final_new_center_lon == -180 and current_calc_lon > 0 ): # Edge case for 180 meridian final_new_center_lon = 180.0 logger.debug( f"MCM pan_map_fixed_step: Clamped & Normalized new center: ({final_new_center_lat:.4f}, {final_new_center_lon:.4f})" ) self._current_center_lat_gui = final_new_center_lat self._current_center_lon_gui = final_new_center_lon logger.info( f"MCM pan_map_fixed_step: Panning map content '{direction}'. Updated GUI center to ({final_new_center_lat:.4f}, {final_new_center_lon:.4f}). Requesting render." ) self._request_map_render( final_new_center_lat, final_new_center_lon, self._current_zoom_gui ) def center_map_and_fit_patch( self, center_lat: float, center_lon: float, patch_size_km: float ): logger.info( f"MCM center_map_and_fit_patch (GUI): 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( "MCM center_map_and_fit_patch: 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 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, ) logger.debug( f"MCM center_map_and_fit_patch: Zoom for width={zoom_for_width}, zoom for height={zoom_for_height}" ) if zoom_for_width is None or zoom_for_height is None: logger.error( f"MCM center_map_and_fit_patch: Could not calculate zoom to fit patch of {patch_size_km}km. Using current or default zoom." ) new_zoom = ( self._current_zoom_gui if self._current_zoom_gui is not None else map_constants.DEFAULT_INITIAL_ZOOM ) else: new_zoom = min( zoom_for_width, zoom_for_height ) # Prendi il più "lontano" per farci stare tutto max_zoom_limit_svc = ( 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_svc)) logger.info( f"MCM center_map_and_fit_patch: Calculated final zoom: {new_zoom}. Target center: ({center_lat:.4f}, {center_lon:.4f})" ) self._current_center_lat_gui = center_lat self._current_center_lon_gui = center_lon self._current_zoom_gui = new_zoom logger.debug( f"MCM center_map_and_fit_patch: Updated GUI state. Requesting render." ) self._request_map_render(center_lat, center_lon, new_zoom) def shutdown_worker(self): logger.info("MapCanvasManager: Shutdown worker requested.") self._map_worker_stop_event.set() # Segnala al worker di fermarsi # Cancella il processore dei risultati nella GUI if self._gui_after_id_result_processor and self.canvas.winfo_exists(): try: self.canvas.after_cancel(self._gui_after_id_result_processor) logger.debug("MapCanvasManager: Cancelled GUI result processor task.") except Exception as e_cancel_gui_proc: logger.warning( f"MapCanvasManager: Error cancelling GUI result processor: {e_cancel_gui_proc}" ) self._gui_after_id_result_processor = None # Invia un messaggio di shutdown speciale al worker per sbloccarlo se è in attesa sulla coda try: self._map_render_request_queue.put_nowait( {"type": RENDER_REQUEST_TYPE_SHUTDOWN, "request_id": -999} ) logger.debug("MapCanvasManager: Sent shutdown signal to worker queue.") except queue.Full: logger.warning( "MapCanvasManager: Worker request queue full, cannot send shutdown signal via queue. Worker might take longer to stop." ) except Exception as e_put_shutdown: logger.error( f"MapCanvasManager: Error sending shutdown signal to worker queue: {e_put_shutdown}" ) if self._map_worker_thread and self._map_worker_thread.is_alive(): logger.info("MapCanvasManager: Waiting for map worker thread to join...") self._map_worker_thread.join( timeout=2.0 ) # Aspetta per un massimo di 2 secondi if self._map_worker_thread.is_alive(): logger.warning( "MapCanvasManager: Map worker thread did not join in time." ) else: logger.info("MapCanvasManager: Map worker thread joined successfully.") else: logger.info( "MapCanvasManager: Map worker thread was not running or already stopped." ) self._map_worker_thread = None # Pulisci le code while not self._map_render_request_queue.empty(): try: self._map_render_request_queue.get_nowait() self._map_render_request_queue.task_done() except Exception: break while not self._map_render_result_queue.empty(): try: self._map_render_result_queue.get_nowait() self._map_render_result_queue.task_done() except Exception: break logger.info("MapCanvasManager: Worker shutdown sequence complete.")