# 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 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.") from .map_utils import PYPROJ_MODULE_LOCALLY_AVAILABLE, MERCANTILE_MODULE_LOCALLY_AVAILABLE 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 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 ) DEFAULT_MAX_TRACK_AGE_SECONDS = 300 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" CANVAS_MARGIN_FACTOR_FOR_BBOX_FIT = 0.10 RENDER_OVERSIZE_FACTOR = 1.5 class MapCanvasManager: def __init__( self, app_controller: Any, tk_canvas: tk.Canvas, initial_bbox_dict: Optional[Dict[str, float]], is_detail_map: bool = False ): self.is_detail_map = is_detail_map self.log_prefix = f"MCM (detail={self.is_detail_map})" logger.info(f">>> {self.log_prefix} __init__ STARTING <<<") if ( not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or map_utils.mercantile is None ): critical_msg = f"{self.log_prefix}: Critical dependencies missing: Pillow or Mercantile. Map disabled." logger.critical(critical_msg) if app_controller and hasattr(app_controller, "main_window") and app_controller.main_window: if hasattr(app_controller.main_window, "show_error_message"): try: app_controller.main_window.show_error_message( "Map Initialization Error", critical_msg.replace(f"{self.log_prefix}: ", "") ) 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"{self.log_prefix} __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, tile_pixel_size=self.map_service.tile_size, ) logger.info(f"{self.log_prefix} __init__: MapTileManager initialized.") self._current_flights_to_display_gui: List[CanonicalFlightState] = [] self.flight_tracks_gui: Dict[str, deque] = {} 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 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() logger.info(f"{self.log_prefix} __init__: All attributes initialized.") logger.info(f"{self.log_prefix} __init__: Attempting to start map worker thread...") self._start_map_worker_thread() logger.info(f"{self.log_prefix} __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"{self.log_prefix} __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) elif not self.is_detail_map: logger.warning(f"{self.log_prefix} __init__ (MAIN MAP): 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"{self.log_prefix} __init__ (MAIN MAP): Using default config BBox. Requesting render.") self._request_map_render_for_bbox(self._target_bbox_input_gui, preserve_current_zoom_if_possible=False) else: logger.critical(f"{self.log_prefix} __init__ (MAIN MAP): Default fallback BBox from config is invalid: {default_bbox_cfg}.") 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"{self.log_prefix} __init__ (MAIN MAP): 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) else: logger.info(f"{self.log_prefix} __init__ (DETAIL MAP): No initial_bbox_dict. Map will be blank until track data is provided.") self._display_placeholder_text("Detail Map\nAwaiting flight data...") self._setup_event_bindings() logger.info(f"{self.log_prefix} __init__: Event bindings set up.") logger.info(f"{self.log_prefix} __init__: Attempting to start GUI result processing...") self._start_gui_result_processing() logger.info(f">>> {self.log_prefix} __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(f"{self.log_prefix} 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=f"MapRenderWorker_detail_{self.is_detail_map}", daemon=True ) self._map_worker_thread.start() logger.info(f"{self.log_prefix} MapRenderWorker thread started successfully.") def _map_render_worker_target(self): worker_initial_settle_delay_seconds = 0.1 thread_name = threading.current_thread().name # ... (log iniziali) ... while not self._map_worker_stop_event.is_set(): request_data = None request_id = -1 try: 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: # ... (come prima) ... 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]: # ... (gestione errore come prima) ... self._map_render_request_queue.task_done() continue target_bbox_to_center_and_draw = request_data.get("target_bbox") # MODIFICATO: Recupera il flag dal payload della richiesta draw_target_bbox_overlay_flag = request_data.get("draw_target_bbox_overlay", False) # Default a False se non trovato flights_to_draw = request_data.get("flights", []) tracks_to_draw = request_data.get("tracks", {}) 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_to_center_and_draw, draw_target_bbox_overlay_flag, # MODIFICATO: Passa il flag recuperato flights_to_draw, tracks_to_draw, max_track_pts_from_req ) # ... (resto del blocco if e try-except come prima) ... 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, } 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: continue except Exception as e: # ... (gestione eccezione come prima) ... 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) 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 # task_done() might raise if called too many times time.sleep(0.5) # Brief pause after an error 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"{self.log_prefix} 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(f"{self.log_prefix} GUI result processing scheduled.") else: logger.warning(f"{self.log_prefix} Canvas does not exist, cannot schedule GUI result processing.") def _execute_render_pipeline( self, center_lat_of_view: float, center_lon_of_view: float, zoom_level_for_view: int, final_canvas_w_px: int, final_canvas_h_px: int, target_bbox_to_center_and_draw: Optional[Dict[str, float]], draw_target_bbox_overlay_flag: bool, current_flights_to_display: List[CanonicalFlightState], flight_tracks: Dict[str, deque], max_track_points_config: int, ) -> Tuple[ Optional[ImageTk.PhotoImage], Optional[Tuple[float, float, float, float]], Optional[str], ]: log_prefix_pipeline = f"{self.log_prefix} WorkerPipeline_Crop" logger.info(f"{log_prefix_pipeline}: STARTING. ViewCenter=({center_lat_of_view:.4f},{center_lon_of_view:.4f}), ViewZoom={zoom_level_for_view}, FinalCanvas=({final_canvas_w_px}x{final_canvas_h_px}), DrawTargetBBoxFlag={draw_target_bbox_overlay_flag}") if target_bbox_to_center_and_draw: logger.debug(f"{log_prefix_pipeline}: Target BBox for potential centering/drawing: {target_bbox_to_center_and_draw}") if not (PIL_IMAGE_LIB_AVAILABLE and MERCANTILE_MODULE_LOCALLY_AVAILABLE and map_utils.mercantile and Image and ImageDraw and ImageTk): return None, None, f"{log_prefix_pipeline}: Core dependencies missing." if final_canvas_w_px <= 0 or final_canvas_h_px <= 0: return None, None, f"{log_prefix_pipeline}: Invalid final canvas dimensions." render_pixel_w_oversized = int(final_canvas_w_px * RENDER_OVERSIZE_FACTOR) render_pixel_h_oversized = int(final_canvas_h_px * RENDER_OVERSIZE_FACTOR) logger.debug(f"{log_prefix_pipeline}: Oversized render pixel dimensions for tile fetching: {render_pixel_w_oversized}x{render_pixel_h_oversized}") oversized_geo_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom( center_lat_of_view, center_lon_of_view, render_pixel_w_oversized, render_pixel_h_oversized, zoom_level_for_view, self.tile_manager.tile_size ) if not oversized_geo_bbox: return None, None, f"{log_prefix_pipeline}: Failed to calculate oversized_geo_bbox for rendering." logger.debug(f"{log_prefix_pipeline}: Oversized GEO BBox for tile stitching: {oversized_geo_bbox}") tile_xy_ranges = get_tile_ranges_for_bbox(oversized_geo_bbox, zoom_level_for_view) if not tile_xy_ranges: err_msg = f"{log_prefix_pipeline}: Failed to get tile ranges for oversized_geo_bbox {oversized_geo_bbox} at Z{zoom_level_for_view}." logger.error(err_msg) try: placeholder_img = Image.new("RGB", (final_canvas_w_px, final_canvas_h_px), map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB) # type: ignore draw = ImageDraw.Draw(placeholder_img) map_drawing._draw_text_on_placeholder(draw, placeholder_img.size, err_msg.replace(f"{log_prefix_pipeline}: ", "")) if ImageTk: return ImageTk.PhotoImage(placeholder_img), None, err_msg except Exception as e_ph: return None, None, f"{log_prefix_pipeline}: Tile range error AND placeholder creation failed: {e_ph}" return None, None, err_msg stitched_map_oversized_pil = self.tile_manager.stitch_map_image(zoom_level_for_view, tile_xy_ranges[0], tile_xy_ranges[1]) if not stitched_map_oversized_pil: return None, None, f"{log_prefix_pipeline}: Failed to stitch oversized map image." actual_stitched_oversized_geo_bounds = self.tile_manager._get_bounds_for_tile_range(zoom_level_for_view, tile_xy_ranges) if not actual_stitched_oversized_geo_bounds: actual_stitched_oversized_geo_bounds = oversized_geo_bbox logger.debug(f"{log_prefix_pipeline}: Stitched OVERSIZED map PIL size: {stitched_map_oversized_pil.size}, GeoBounds: {actual_stitched_oversized_geo_bounds}") target_center_x_on_oversized_px: float target_center_y_on_oversized_px: float # Il "centro" per il crop è il centro della VISTA RICHIESTA, che è già stato calcolato # da _request_map_render_for_bbox per posizionare il target_bbox_to_center_and_draw con i margini. # Convertiamo il centro della VISTA (center_lat_of_view, center_lon_of_view) # in pixel sull'immagine sovradimensionata. Questo sarà il centro del nostro crop. center_of_view_px_tuple = map_drawing._geo_to_pixel_on_unscaled_map( center_lat_of_view, center_lon_of_view, actual_stitched_oversized_geo_bounds, stitched_map_oversized_pil.size ) if center_of_view_px_tuple: target_center_x_on_oversized_px, target_center_y_on_oversized_px = center_of_view_px_tuple else: logger.warning(f"{log_prefix_pipeline}: Could not convert view center to pixel. Cropping from center of oversized image.") target_center_x_on_oversized_px = stitched_map_oversized_pil.width / 2.0 target_center_y_on_oversized_px = stitched_map_oversized_pil.height / 2.0 logger.debug(f"{log_prefix_pipeline}: Effective center for crop (px on oversized): ({target_center_x_on_oversized_px:.1f}, {target_center_y_on_oversized_px:.1f})") crop_x0 = round(target_center_x_on_oversized_px - (final_canvas_w_px / 2.0)) crop_y0 = round(target_center_y_on_oversized_px - (final_canvas_h_px / 2.0)) crop_x1 = crop_x0 + final_canvas_w_px crop_y1 = crop_y0 + final_canvas_h_px calculated_crop_box_pil = (crop_x0, crop_y0, crop_x1, crop_y1) logger.info(f"{log_prefix_pipeline}: Initial CROP BOX (on stitched_map_oversized {stitched_map_oversized_pil.size}): {calculated_crop_box_pil} for final canvas {final_canvas_w_px}x{final_canvas_h_px}") actual_crop_x0 = max(0, calculated_crop_box_pil[0]) actual_crop_y0 = max(0, calculated_crop_box_pil[1]) actual_crop_x1 = min(stitched_map_oversized_pil.width, calculated_crop_box_pil[2]) actual_crop_y1 = min(stitched_map_oversized_pil.height, calculated_crop_box_pil[3]) adjusted_crop_box = (actual_crop_x0, actual_crop_y0, actual_crop_x1, actual_crop_y1) cropped_image_segment = stitched_map_oversized_pil.crop(adjusted_crop_box) logger.info(f"{log_prefix_pipeline}: Adjusted crop box: {adjusted_crop_box}, Cropped segment size: {cropped_image_segment.size}") final_image_for_canvas_pil = Image.new("RGB", (final_canvas_w_px, final_canvas_h_px), map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB) # type: ignore paste_x = (final_canvas_w_px - cropped_image_segment.width) // 2 paste_y = (final_canvas_h_px - cropped_image_segment.height) // 2 final_image_for_canvas_pil.paste(cropped_image_segment, (paste_x, paste_y)) final_map_geo_bounds_for_overlays = calculate_geographic_bbox_from_pixel_size_and_zoom( center_lat_of_view, center_lon_of_view, final_canvas_w_px, final_canvas_h_px, zoom_level_for_view, self.tile_manager.tile_size ) if not final_map_geo_bounds_for_overlays: logger.error(f"{log_prefix_pipeline}: Critical error calculating final_map_geo_bounds_for_overlays. Using stitched oversized bounds as fallback.") final_map_geo_bounds_for_overlays = actual_stitched_oversized_geo_bounds # Fallback logger.info(f"{log_prefix_pipeline}: Final image size after crop/pad: {final_image_for_canvas_pil.size}, GeoBounds for overlays: {final_map_geo_bounds_for_overlays}") if final_image_for_canvas_pil.mode != "RGBA": image_to_draw_on = final_image_for_canvas_pil.convert("RGBA") else: image_to_draw_on = final_image_for_canvas_pil.copy() img_shape_for_drawing = image_to_draw_on.size draw = ImageDraw.Draw(image_to_draw_on) if draw_target_bbox_overlay_flag and target_bbox_to_center_and_draw and _is_valid_bbox_dict(target_bbox_to_center_and_draw): bbox_wesn = (target_bbox_to_center_and_draw["lon_min"], target_bbox_to_center_and_draw["lat_min"], target_bbox_to_center_and_draw["lon_max"], target_bbox_to_center_and_draw["lat_max"]) try: map_drawing.draw_area_bounding_box( image_to_draw_on, bbox_wesn, final_map_geo_bounds_for_overlays, img_shape_for_drawing, color=map_constants.AREA_BOUNDARY_COLOR, thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX ) logger.debug(f"{log_prefix_pipeline}: Drew target BBox overlay on final image because flag was True.") except Exception as e_bbox_draw_final: logger.error(f"{log_prefix_pipeline}: Error drawing target BBox overlay on final image: {e_bbox_draw_final}", exc_info=False) elif target_bbox_to_center_and_draw: # Logga anche se non lo disegna logger.debug(f"{log_prefix_pipeline}: Skipped drawing target BBox overlay because flag was False (is_detail_map={self.is_detail_map}).") if current_flights_to_display: font_size = map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE + (zoom_level_for_view - map_constants.DEM_TILE_LABEL_BASE_ZOOM) label_font = map_drawing._load_label_font(font_size) 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( flight.latitude, flight.longitude, final_map_geo_bounds_for_overlays, img_shape_for_drawing) if pixel_coords_f: current_flight_track_deque = flight_tracks.get(flight.icao24) map_drawing._draw_single_flight( draw, pixel_coords_f, flight, label_font, track_deque=current_flight_track_deque, current_map_geo_bounds=final_map_geo_bounds_for_overlays, current_stitched_map_pixel_shape=img_shape_for_drawing) logger.debug(f"{log_prefix_pipeline}: Drew {len(current_flights_to_display)} flight overlays on final image.") try: if ImageTk: final_photo_image = ImageTk.PhotoImage(image_to_draw_on) return final_photo_image, final_map_geo_bounds_for_overlays, None else: return None, final_map_geo_bounds_for_overlays, f"{log_prefix_pipeline}: ImageTk module not available." except Exception as e_photo_pipe_final: return None, final_map_geo_bounds_for_overlays, f"{log_prefix_pipeline}: Failed to create final PhotoImage: {e_photo_pipe_final}" def _process_map_render_results(self): if not self.canvas.winfo_exists(): logger.info(f"{self.log_prefix} Canvas destroyed, stopping map render result processing.") self._gui_after_id_result_processor = None return 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") # Questi sono i bounds dell'immagine ricevuta error_message = result_data.get("error") logger.info(f"{self.log_prefix} 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"{self.log_prefix} 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"{self.log_prefix} GUI ResultsProcessor: Received error from Worker (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"{self.log_prefix} 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 # Salva i bounds dell'immagine visualizzata else: logger.warning(f"{self.log_prefix} GUI ResultsProcessor: Received invalid/empty result from worker for ReqID {request_id}. Error: '{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 not self.is_detail_map: if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): logger.debug(f"{self.log_prefix} GUI ResultsProcessor (MAIN MAP): Requesting controller to update general map info after ReqID {request_id}.") self.app_controller.update_general_map_info() except queue.Empty: pass except Exception as e: logger.exception(f"{self.log_prefix} 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) 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"{self.log_prefix} 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(f"{self.log_prefix} 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"{self.log_prefix} GUI _request_map_render: New request ID {current_request_id}. Expected GUI ID set to {self._expected_render_id_gui}.") flights_copy = [] try: flights_copy = copy.deepcopy(self._current_flights_to_display_gui) except Exception as e: logger.error(f"{self.log_prefix} Error deepcopying flights for render: {e}") if self.is_detail_map and flights_copy: # current_detail_icao dovrebbe essere l'icao del volo live, che dovrebbe corrispondere a quello della finestra # se _current_flights_to_display_gui è stato popolato correttamente per la mappa dettaglio current_detail_icao = flights_copy[0].icao24 logger.info(f"{self.log_prefix} _request_map_render: BEFORE deepcopy, content of self.flight_tracks_gui for ICAO {current_detail_icao}: {self.flight_tracks_gui.get(current_detail_icao)}") tracks_copy = {} try: tracks_copy = copy.deepcopy(self.flight_tracks_gui) if self.is_detail_map and flights_copy: current_detail_icao = flights_copy[0].icao24 logger.info(f"{self.log_prefix} _request_map_render: AFTER deepcopy, content of tracks_copy for ICAO {current_detail_icao}: {tracks_copy.get(current_detail_icao)}") except Exception as e_copy_t: logger.error(f"{self.log_prefix} Error deepcopying tracks for render: {e_copy_t}") # ensure_bbox_is_covered_dict è il BBox che vogliamo vedere centrato e potenzialmente disegnato # Per la mappa dei dettagli, questo sarà il track_bbox calcolato da FullFlightDetailsWindow. # Per la mappa principale, sarà il BBox di monitoraggio. target_bbox_to_pass_to_worker = None if ensure_bbox_is_covered_dict: try: target_bbox_to_pass_to_worker = copy.deepcopy(ensure_bbox_is_covered_dict) except Exception as e: logger.error(f"{self.log_prefix} Error deepcopying ensure_bbox_is_covered_dict for render: {e}") elif not self.is_detail_map and self._target_bbox_input_gui : try: target_bbox_to_pass_to_worker = copy.deepcopy(self._target_bbox_input_gui) except Exception as e: logger.error(f"{self.log_prefix} Error deepcopying self._target_bbox_input_gui for render: {e}") # Determina se l'overlay del BBox target deve essere disegnato # Non lo disegniamo per la mappa dei dettagli (vogliamo solo la traccia) # Lo disegniamo per la mappa principale (è il BBox di monitoraggio) should_draw_target_bbox_overlay = not self.is_detail_map 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_pass_to_worker, # Passa il BBox che deve essere centrato/disegnato "draw_target_bbox_overlay": should_draw_target_bbox_overlay, "flights": flights_copy, "tracks": tracks_copy, "max_track_points": self.max_track_points, } try: self._clear_canvas_display_elements() loading_text = f"Loading Map...\nZ{zoom_level} @ ({center_lat:.2f}, {center_lon:.2f})\nReqID: {current_request_id}" self._display_placeholder_text(loading_text) self._map_render_request_queue.put_nowait(request_payload) logger.info(f"{self.log_prefix} GUI _request_map_render: Successfully queued map render request ID {current_request_id} (Z{zoom_level}). DrawTargetOverlay: {should_draw_target_bbox_overlay}, TargetBBox: {target_bbox_to_pass_to_worker is not None}") self._current_center_lat_gui = center_lat self._current_center_lon_gui = center_lon self._current_zoom_gui = zoom_level except queue.Full: logger.warning(f"{self.log_prefix} Map render request queue FULL. Request ID {current_request_id} was DROPPED.") self._display_placeholder_text("Map Busy / Request Queue Full.\nPlease Try Action Again.") except Exception as e: logger.exception(f"{self.log_prefix} 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(f"{self.log_prefix} Event bindings set up.") 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)): logger.info(f"{self.log_prefix} _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) except Exception: pass 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) def _perform_resize_redraw(self, width: int, height: int): self._resize_debounce_job_id = None if not self.canvas.winfo_exists(): logger.warning(f"{self.log_prefix} _perform_resize_redraw: Canvas does not exist. Aborting.") return logger.info(f"{self.log_prefix} _perform_resize_redraw: Executing debounced resize to {width}x{height}. Requesting new render.") self.canvas_width, self.canvas_height = width, 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): 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(f"{self.log_prefix} _perform_resize_redraw: Cannot redraw on resize - current map center/zoom not set.") self._display_placeholder_text("Map State Error\n(Cannot redraw on resize)") def _on_left_button_press(self, event: tk.Event): if not self.canvas.winfo_exists(): return logger.debug(f"{self.log_prefix} _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"{self.log_prefix} Left Button Release: CanvasX={event.x}, CanvasY={event.y}") if not self._is_left_button_pressed: self._drag_start_x_canvas, self._drag_start_y_canvas = None, None return self._is_left_button_pressed = False clicked_flight_icao: Optional[str] = None if self._drag_start_x_canvas is not None and self._drag_start_y_canvas is not None: drag_threshold_px = 10 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 if is_click: logger.debug(f"{self.log_prefix} 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( 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 ({self.log_prefix}) at Geo ({clicked_lat:.5f}, {clicked_lon:.5f})") if not self.is_detail_map and self.app_controller: if 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) min_dist_sq_to_flight = float("inf") flight_click_radius_px_sq = 15 * 15 with self._map_data_lock: flights_on_map = self._current_flights_to_display_gui if flights_on_map: for flight in flights_on_map: if flight.latitude is not None and flight.longitude is not None: flight_px_coords = map_drawing._geo_to_pixel_on_unscaled_map( flight.latitude, flight.longitude, self._current_map_geo_bounds_gui, map_pixel_shape ) if flight_px_coords: 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 ({self.log_prefix}): {clicked_flight_icao}") if hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info(clicked_flight_icao) elif not self.is_detail_map: logger.info(f"No specific flight selected by click on MAIN map. Clearing details.") if hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") else: logger.warning(f"Failed to convert left click pixel ({event.x},{event.y}) to geo ({self.log_prefix}).") if not self.is_detail_map and self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") except Exception as e_click_proc: logger.error(f"Error during left click processing ({self.log_prefix}): {e_click_proc}", exc_info=True) if not self.is_detail_map and self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") else: logger.warning(f"Map context missing for left click ({self.log_prefix}).") if not self.is_detail_map and self.app_controller and hasattr(self.app_controller, "request_detailed_flight_info"): self.app_controller.request_detailed_flight_info("") 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"{self.log_prefix} _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(f"Map context missing for right click geo conversion ({self.log_prefix}).") return try: map_pixel_shape = (self._map_photo_image.width(), self._map_photo_image.height()) geo_lon, geo_lat = map_utils._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 ({self.log_prefix}) at Geo ({geo_lat:.5f}, {geo_lon:.5f})") if not self.is_detail_map and self.app_controller: if hasattr(self.app_controller, "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 ({self.log_prefix}).") except Exception as e_rclick_convert: logger.error(f"Error during right click geo conversion ({self.log_prefix}): {e_rclick_convert}", exc_info=True) def set_max_track_points(self, length: int): new_length = max(2, length) if self.max_track_points != new_length: logger.info(f"{self.log_prefix}: Max track points updating from {self.max_track_points} to {new_length}") with self._map_data_lock: self.max_track_points = new_length for icao in list(self.flight_tracks_gui.keys()): track_deque = self.flight_tracks_gui.get(icao) if track_deque: new_deque_for_icao = deque(maxlen=self.max_track_points + 5) start_index = max(0, len(track_deque) - self.max_track_points) for i in range(start_index, len(track_deque)): new_deque_for_icao.append(track_deque[i]) if not new_deque_for_icao: if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao] else: self.flight_tracks_gui[icao] = new_deque_for_icao 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): self._request_map_render(self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui) def set_target_bbox(self, new_bbox_dict: Dict[str, float]): logger.info(f"{self.log_prefix} 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: self._target_bbox_input_gui = new_bbox_dict.copy() logger.debug(f"{self.log_prefix} set_target_bbox: _target_bbox_input_gui updated. Requesting render.") self._request_map_render_for_bbox(self._target_bbox_input_gui, preserve_current_zoom_if_possible=False) if not self.is_detail_map: 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"{self.log_prefix} set_target_bbox: Invalid/empty new_bbox_dict: {new_bbox_dict}. Clearing.") 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): self._request_map_render(self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui) else: self.clear_map_display() if not self.is_detail_map: if self.app_controller and hasattr(self.app_controller, "update_bbox_gui_fields"): self.app_controller.update_bbox_gui_fields({}) def _request_map_render_for_bbox( self, target_bbox_dict: Dict[str, float], preserve_current_zoom_if_possible: bool = False, ): log_prefix_rq = f"{self.log_prefix} _req_map_for_bbox" # Log prefix più corto logger.debug(f"{log_prefix_rq}: Target BBox to fit: {target_bbox_dict}") if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict): logger.warning(f"{log_prefix_rq}: Invalid target BBox. Aborting.") if not self.is_detail_map: self._display_placeholder_text("Invalid BBox for Map Render") return lat_min_orig, lon_min_orig, lat_max_orig, lon_max_orig = ( 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_orig + lat_max_orig) / 2.0, (lon_min_orig + lon_max_orig) / 2.0 if lon_min_orig > lon_max_orig: view_center_lon = (lon_min_orig + (lon_max_orig + 360.0)) / 2.0 if view_center_lon >= 180.0: view_center_lon -= 360.0 zoom_to_request = self._current_zoom_gui # Se preserve_current_zoom_if_possible è False, o non c'è uno zoom corrente, calcoliamo lo zoom. if not preserve_current_zoom_if_possible or zoom_to_request is None: original_width_km, original_height_km = calculate_geographic_bbox_size_km( (lon_min_orig, lat_min_orig, lon_max_orig, lat_max_orig) ) or (None, None) if original_width_km is None or original_height_km is None: logger.error(f"{log_prefix_rq}: Could not calculate original BBox size for zoom. Using current or default.") zoom_to_request = self._current_zoom_gui if self._current_zoom_gui is not None else map_constants.DEFAULT_INITIAL_ZOOM else: logger.debug(f"{log_prefix_rq}: Original BBox size for zoom calc: {original_width_km:.2f}km x {original_height_km:.2f}km") # Calcola le dimensioni effettive del canvas in cui il BBox originale deve entrare (considerando il margine) # Se il margine è 10% (0.10), il BBox deve entrare nel 100% - 2*10% = 80% del canvas. effective_pixel_width_for_target = self.canvas_width * (1.0 - 2 * CANVAS_MARGIN_FACTOR_FOR_BBOX_FIT) effective_pixel_height_for_target = self.canvas_height * (1.0 - 2 * CANVAS_MARGIN_FACTOR_FOR_BBOX_FIT) effective_pixel_width_for_target = max(1.0, effective_pixel_width_for_target) # Evita dimensioni zero/negative effective_pixel_height_for_target = max(1.0, effective_pixel_height_for_target) logger.debug(f"{log_prefix_rq}: Target BBox should fit in canvas area: {effective_pixel_width_for_target:.0f}x{effective_pixel_height_for_target:.0f} px") if self.canvas_width > 0 and self.canvas_height > 0: zoom_w = calculate_zoom_level_for_geographic_size( view_center_lat, original_width_km * 1000, int(effective_pixel_width_for_target), self.tile_manager.tile_size ) zoom_h = calculate_zoom_level_for_geographic_size( view_center_lat, original_height_km * 1000, int(effective_pixel_height_for_target), self.tile_manager.tile_size ) calculated_zoom = map_constants.DEFAULT_INITIAL_ZOOM if zoom_w is not None and zoom_h is not None: calculated_zoom = min(zoom_w, zoom_h) elif zoom_w is not None: calculated_zoom = zoom_w elif zoom_h is not None: calculated_zoom = zoom_h zoom_to_request = calculated_zoom logger.info(f"{log_prefix_rq}: Calculated zoom to fit BBox with margin: {zoom_to_request}") else: logger.warning(f"{log_prefix_rq}: Canvas size invalid. Using current or default zoom.") zoom_to_request = self._current_zoom_gui if self._current_zoom_gui is not None else map_constants.DEFAULT_INITIAL_ZOOM max_zoom_svc = self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK final_zoom_for_request = max(map_constants.MIN_ZOOM_LEVEL, min(zoom_to_request, max_zoom_svc)) logger.info(f"{log_prefix_rq}: Final zoom for request (to worker): {final_zoom_for_request}") self._request_map_render( center_lat=view_center_lat, center_lon=view_center_lon, zoom_level=final_zoom_for_request, ensure_bbox_is_covered_dict=target_bbox_dict ) def _clear_canvas_display_elements(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 if self._map_photo_image is not None: self._map_photo_image = None if self._placeholder_text_id is not None and self.canvas.winfo_exists(): try: self.canvas.delete(self._placeholder_text_id) except Exception: pass finally: self._placeholder_text_id = None def _display_placeholder_text(self, text: str): logger.debug(f"{self.log_prefix} _display_placeholder_text: Displaying '{text[:50]}...'") if not self.canvas.winfo_exists(): logger.warning(f"{self.log_prefix} _display_placeholder_text: Canvas does not exist.") return self._clear_canvas_display_elements() 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 if canvas_h_disp <= 1: canvas_h_disp = self.canvas_height 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) 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) ) except tk.TclError: pass except Exception as e: logger.error(f"{self.log_prefix} _display_placeholder_text: Unexpected error: {e}", exc_info=True) def clear_map_display(self): logger.info(f"{self.log_prefix} 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 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"{self.log_prefix} 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 ) elif not self.is_detail_map: if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): self.app_controller.update_general_map_info() def update_flights_on_map(self, flight_states: List[CanonicalFlightState]): logger.info(f"{self.log_prefix} update_flights_on_map (GUI): Received {len(flight_states)} flight states.") with self._map_data_lock: self._current_flights_to_display_gui = flight_states if not flight_states: self.flight_tracks_gui.clear() else: 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: self.flight_tracks_gui[state.icao24] = deque(maxlen=self.max_track_points + 5) 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: tracks_to_remove_gui.append(icao); continue if icao not in active_icao_this_update: last_point_time = track_deque[-1][2] if current_time - last_point_time > self.max_track_age_seconds: tracks_to_remove_gui.append(icao) if tracks_to_remove_gui: for icao in tracks_to_remove_gui: if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao] 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"{self.log_prefix} 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) def get_current_map_info(self) -> Dict[str, Any]: logger.debug(f"{self.log_prefix} get_current_map_info (GUI) called.") map_size_km_w, map_size_km_h = None, None if (PYPROJ_MODULE_LOCALLY_AVAILABLE and map_utils.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 ({self.log_prefix}): {e_map_size_info}", exc_info=False) with self._map_data_lock: 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"{self.log_prefix} 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 ): if self.is_detail_map: logger.warning("show_map_context_menu_from_gui called on a detail map. Ignoring.") return 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() 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: 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 context menu: {e}.") except Exception as e: logger.error(f"Error creating/showing MapCanvasManager context menu: {e}", exc_info=True) def recenter_map_at_coords(self, lat: float, lon: float): logger.info(f"{self.log_prefix} 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 self._request_map_render(lat, lon, self._current_zoom_gui) else: logger.warning(f"{self.log_prefix} 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): if self.is_detail_map: logger.warning(f"{self.log_prefix} set_bbox_around_coords called on a DETAIL map. This action is for the main map.") return logger.info(f"{self.log_prefix} set_bbox_around_coords (MAIN MAP): 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 map_utils.pyproj is None: logger.error(f"{self.log_prefix} set_bbox_around_coords: Cannot set BBox - pyproj library not available.") if self.app_controller and hasattr(self.app_controller, "main_window") and self.app_controller.main_window and hasattr(self.app_controller.main_window, "show_error_message"): self.app_controller.main_window.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"{self.log_prefix} set_bbox_around_coords: Calculated BBox is invalid: {bbox_dict}.") if self.app_controller and hasattr(self.app_controller, "main_window") and self.app_controller.main_window and hasattr(self.app_controller.main_window, "show_error_message"): self.app_controller.main_window.show_error_message("Map Error", "Calculated BBox is invalid.") else: logger.error(f"{self.log_prefix} set_bbox_around_coords: Failed to calculate BBox around coords.") if self.app_controller and hasattr(self.app_controller, "main_window") and self.app_controller.main_window and hasattr(self.app_controller.main_window, "show_error_message"): self.app_controller.main_window.show_error_message("Map Error", "Failed to calculate BBox around coordinates.") except Exception as e: logger.exception(f"{self.log_prefix} set_bbox_around_coords: Unexpected error calculating BBox: {e}") if self.app_controller and hasattr(self.app_controller, "main_window") and self.app_controller.main_window and hasattr(self.app_controller.main_window, "show_error_message"): self.app_controller.main_window.show_error_message("Map Error", f"An unexpected error occurred calculating BBox: {e}") def zoom_in_at_center(self): logger.debug(f"{self.log_prefix} 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(f"{self.log_prefix} zoom_in_at_center: Cannot zoom in - current map state is not defined.") return if not self.canvas.winfo_exists(): 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) if new_zoom != self._current_zoom_gui: self._current_zoom_gui = new_zoom self._request_map_render(self._current_center_lat_gui, self._current_center_lon_gui, new_zoom) def zoom_out_at_center(self): logger.debug(f"{self.log_prefix} 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(f"{self.log_prefix} zoom_out_at_center: Cannot zoom out - current map state is not defined.") return if not self.canvas.winfo_exists(): return new_zoom = max(map_constants.MIN_ZOOM_LEVEL, self._current_zoom_gui - 1) if new_zoom != self._current_zoom_gui: self._current_zoom_gui = new_zoom self._request_map_render(self._current_center_lat_gui, self._current_center_lon_gui, new_zoom) def pan_map_fixed_step(self, direction: str, step_fraction: float = PAN_STEP_FRACTION): logger.debug(f"{self.log_prefix} pan_map_fixed_step (GUI) called. Direction: {direction}") 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(f"{self.log_prefix} pan_map_fixed_step: Cannot pan map - current map state not fully defined.") return if (not self.canvas.winfo_exists() or not PYPROJ_MODULE_LOCALLY_AVAILABLE or map_utils.pyproj is None): logger.warning(f"{self.log_prefix} 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) 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"{self.log_prefix} pan_map_fixed_step: Unknown pan direction: {direction}"); return 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(f"{self.log_prefix} pan_map_fixed_step: 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 = map_utils.pyproj.Geod(ellps="WGS84") current_calc_lon, current_calc_lat = self._current_center_lon_gui, self._current_center_lat_gui if abs(delta_meters_x) > 1e-9: azimuth_lon = 90.0 if delta_meters_x > 0 else 270.0 new_lon, _, _ = geod.fwd(current_calc_lon, current_calc_lat, azimuth_lon, abs(delta_meters_x)) current_calc_lon = new_lon if abs(delta_meters_y) > 1e-9: azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0 _, new_lat, _ = geod.fwd(current_calc_lon, current_calc_lat, azimuth_lat, abs(delta_meters_y)) current_calc_lat = new_lat MAX_MERCATOR_LAT = 85.05112878 final_new_center_lat = max(-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, current_calc_lat)) final_new_center_lon = (current_calc_lon + 180) % 360 - 180 if final_new_center_lon == -180 and current_calc_lon > 0: final_new_center_lon = 180.0 self._current_center_lat_gui = final_new_center_lat self._current_center_lon_gui = final_new_center_lon 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"{self.log_prefix} 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(f"{self.log_prefix} center_map_and_fit_patch: Cannot fit patch - canvas not ready or invalid dimensions.") if not self.is_detail_map and self.app_controller and hasattr(self.app_controller, "main_window") and self.app_controller.main_window and hasattr(self.app_controller.main_window, "show_error_message"): self.app_controller.main_window.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) if zoom_for_width is None or zoom_for_height is None: logger.error(f"{self.log_prefix} center_map_and_fit_patch: Could not calculate zoom to fit patch. 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) 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)) # Apply extra zoom out for margin when fitting a patch if new_zoom > map_constants.MIN_ZOOM_LEVEL: new_zoom -=1 # Zoom out by one level logger.info(f"{self.log_prefix} center_map_and_fit_patch: Applied -1 zoom margin for patch. New zoom: {new_zoom}") self._current_center_lat_gui = center_lat self._current_center_lon_gui = center_lon self._current_zoom_gui = new_zoom self._request_map_render(center_lat, center_lon, new_zoom) def shutdown_worker(self): logger.info(f"{self.log_prefix} Shutdown worker requested.") self._map_worker_stop_event.set() if self._gui_after_id_result_processor and self.canvas.winfo_exists(): try: self.canvas.after_cancel(self._gui_after_id_result_processor) except Exception: pass self._gui_after_id_result_processor = None try: self._map_render_request_queue.put_nowait({"type": RENDER_REQUEST_TYPE_SHUTDOWN, "request_id": -999}) except queue.Full: logger.warning(f"{self.log_prefix} Worker request queue full, cannot send shutdown signal via queue.") except Exception as e: logger.error(f"{self.log_prefix} Error sending shutdown signal to worker queue: {e}") if self._map_worker_thread and self._map_worker_thread.is_alive(): logger.info(f"{self.log_prefix} Waiting for map worker thread to join...") self._map_worker_thread.join(timeout=2.0) if self._map_worker_thread.is_alive(): logger.warning(f"{self.log_prefix} Map worker thread did not join in time.") else: logger.info(f"{self.log_prefix} Map worker thread joined successfully.") self._map_worker_thread = None 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(f"{self.log_prefix} Worker shutdown sequence complete.")