diff --git a/flightmonitor/map/map_canvas_manager.py b/flightmonitor/map/map_canvas_manager.py index c136eb7..1a8ecdb 100644 --- a/flightmonitor/map/map_canvas_manager.py +++ b/flightmonitor/map/map_canvas_manager.py @@ -7,26 +7,19 @@ from typing import Optional, Tuple, List, Dict, Any from collections import deque import queue import threading -from . import map_utils # map_utils importa pyproj e mercantile +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 + Image, ImageTk, ImageDraw, ImageFont = None, None, None, None # type: ignore PIL_IMAGE_LIB_AVAILABLE = False - import logging + import logging + logging.error("MapCanvasManager: Pillow (Image, ImageTk, ImageDraw, ImageFont) not found. Map disabled.") - 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 .map_utils import PYPROJ_MODULE_LOCALLY_AVAILABLE, MERCANTILE_MODULE_LOCALLY_AVAILABLE from . import map_constants from ..data import config as app_config @@ -47,11 +40,9 @@ 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.") @@ -62,7 +53,9 @@ MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr( ) 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_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 @@ -70,6 +63,9 @@ 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: @@ -78,54 +74,42 @@ class MapCanvasManager: app_controller: Any, tk_canvas: tk.Canvas, initial_bbox_dict: Optional[Dict[str, float]], - is_detail_map: bool = False, + is_detail_map: bool = False ): self.is_detail_map = is_detail_map - self.log_prefix = f"MCM (detail={self.is_detail_map})" # Store log_prefix + 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 # Check the imported module in map_utils + 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"): + 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}: ", ""), + "Map Initialization Error", critical_msg.replace(f"{self.log_prefix}: ", "") ) - except Exception: - pass + 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 + 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}" - ) + 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._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 @@ -152,72 +136,40 @@ class MapCanvasManager: 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..." - ) + 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}" - ) + 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 - ) + 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." - ) + 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, + "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 - ) + 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}." - ) + 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_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, - ) + 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...") + 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() # ORA QUESTO DOVREBBE FUNZIONARE + 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..." - ) + 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 <<<") @@ -230,7 +182,7 @@ class MapCanvasManager: self._map_worker_thread = threading.Thread( target=self._map_render_worker_target, name=f"MapRenderWorker_detail_{self.is_detail_map}", - daemon=True, + daemon=True ) self._map_worker_thread.start() logger.info(f"{self.log_prefix} MapRenderWorker thread started successfully.") @@ -238,46 +190,24 @@ class MapCanvasManager: 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." - ) + # ... (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_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}" - ) + # 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." - ) + # ... (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}" - ) + # 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") @@ -285,92 +215,58 @@ class MapCanvasManager: 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) + # ... (gestione errore come prima) ... self._map_render_request_queue.task_done() continue - - target_bbox = request_data.get("target_bbox") + + 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, - flights_to_draw, - tracks_to_draw, - max_track_pts_from_req, - ) + 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 + 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, + "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." - ) + 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}." - ) - + 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: - logger.exception( - f"{thread_name}: Unhandled exception in worker loop for request ID {request_id if request_data else 'N/A'}: {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__}", - } + 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." - ) + 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}" - ) + 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) + 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): @@ -379,27 +275,24 @@ class MapCanvasManager: 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}" - ) + 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." - ) + logger.warning(f"{self.log_prefix} 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]], + 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, @@ -408,206 +301,159 @@ class MapCanvasManager: Optional[Tuple[float, float, float, float]], Optional[str], ]: - log_prefix_pipeline = ( - f"{self.log_prefix} WorkerPipeline" # Usa il log_prefix dell'istanza + 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 ) - logger.debug( - f"{self.log_prefix} WorkerPipeline: Starting for Z{zoom_level}, Center ({center_lat:.4f},{center_lon:.4f}), Canvas {canvas_w}x{canvas_h}" - ) - - # >>> INIZIO BLOCCO DEBUG TRACCIA <<< - if self.is_detail_map and current_flights_to_display: - detail_icao = current_flights_to_display[ - 0 - ].icao24 # Assumiamo che ci sia un solo volo per la mappa dettaglio - track_for_icao_in_worker = flight_tracks.get(detail_icao) - logger.info(f"{log_prefix_pipeline}: DETAIL MAP for ICAO {detail_icao}.") - logger.info( - f"{log_prefix_pipeline}: - current_flights_to_display (live point): {current_flights_to_display}" - ) - if track_for_icao_in_worker: - logger.info( - f"{log_prefix_pipeline}: - Track from flight_tracks (deque length): {len(track_for_icao_in_worker)}" - ) - # Stampa i primi e ultimi punti della deque per un controllo - # if len(track_for_icao_in_worker) > 0: - # logger.info(f"{log_prefix_pipeline}: Track head: {list(track_for_icao_in_worker)[0]}") - # if len(track_for_icao_in_worker) > 1: - # logger.info(f"{log_prefix_pipeline}: Track tail: {list(track_for_icao_in_worker)[-1]}") - else: - logger.info( - f"{log_prefix_pipeline}: - No track found in flight_tracks for ICAO {detail_icao}." - ) - # >>> FINE BLOCCO DEBUG TRACCIA <<< - - if ( - not PIL_IMAGE_LIB_AVAILABLE - or not MERCANTILE_MODULE_LOCALLY_AVAILABLE - or map_utils.mercantile is None - or Image is None - or ImageDraw is None - or ImageTk is None - ): - err_msg = f"{self.log_prefix} 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"{self.log_prefix} 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 = f"{self.log_prefix} WorkerPipeline: Failed to calculate canvas geographic BBox." - logger.error(err_msg) - return None, None, err_msg - - tile_xy_ranges = get_tile_ranges_for_bbox(canvas_geo_bbox, zoom_level) + 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"{self.log_prefix} WorkerPipeline: Failed to get tile ranges for {canvas_geo_bbox} at Z{zoom_level}." + 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", - (canvas_w, canvas_h), - map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB, - ) + 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"{self.log_prefix} WorkerPipeline: ", ""), - ) - if ImageTk: - return ImageTk.PhotoImage(placeholder_img), None, err_msg - else: - return ( - None, - None, - f"{self.log_prefix} WorkerPipeline: ImageTk unavailable for placeholder.", - ) - except Exception as e_ph: - return ( - None, - None, - f"{self.log_prefix} WorkerPipeline: Tile range error AND placeholder creation failed: {e_ph}", - ) - - 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 = f"{self.log_prefix} WorkerPipeline: Failed to stitch map image from TileManager." - logger.error(err_msg) + 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 - 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( - f"{self.log_prefix} WorkerPipeline: Could not determine actual stitched map geo bounds. Using canvas_geo_bbox as fallback." - ) - actual_stitched_map_geo_bounds = canvas_geo_bbox - if stitched_map_pil.mode != "RGBA": - image_to_draw_on = stitched_map_pil.convert("RGBA") + 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 = stitched_map_pil.copy() - img_shape = image_to_draw_on.size + 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 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"], - ) + 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, - actual_stitched_map_geo_bounds, - img_shape, - color=map_constants.AREA_BOUNDARY_COLOR, - thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX, - ) - except Exception as e_bbox_draw_pipe: - logger.error( - f"{self.log_prefix} WorkerPipeline: Error drawing target BBox: {e_bbox_draw_pipe}", - exc_info=False, + 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 - map_constants.DEM_TILE_LABEL_BASE_ZOOM - ) + 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) - 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( - flight.latitude, - flight.longitude, - actual_stitched_map_geo_bounds, - img_shape, - ) + flight.latitude, flight.longitude, final_map_geo_bounds_for_overlays, img_shape_for_drawing) if pixel_coords_f: - try: - 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=actual_stitched_map_geo_bounds, - current_stitched_map_pixel_shape=img_shape, - ) - flights_drawn_count += 1 - except Exception as e_flight_draw_pipe: - logger.error( - f"{self.log_prefix} WorkerPipeline: Error drawing flight {flight.icao24}: {e_flight_draw_pipe}", - exc_info=False, - ) - logger.debug( - f"{self.log_prefix} WorkerPipeline: Drew {flights_drawn_count} flight markers and tracks." - ) + 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) - logger.debug( - f"{self.log_prefix} WorkerPipeline: Successfully created PhotoImage." - ) - return final_photo_image, actual_stitched_map_geo_bounds, None + return final_photo_image, final_map_geo_bounds_for_overlays, None else: - return ( - None, - actual_stitched_map_geo_bounds, - f"{self.log_prefix} WorkerPipeline: ImageTk module not available.", - ) - except Exception as e_photo_pipe: - err_msg_photo = f"{self.log_prefix} 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 + 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." - ) + logger.info(f"{self.log_prefix} Canvas destroyed, stopping map render result processing.") self._gui_after_id_result_processor = None return @@ -618,91 +464,54 @@ class MapCanvasManager: 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") + 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}" - ) + 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})." - ) + 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}." - ) + 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 + 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})" - ) - + 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}." - ) + 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 queue.Empty: pass except Exception as e: - logger.exception( - f"{self.log_prefix} GUI ResultsProcessor: Error processing map render results: {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 - ) + 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, + 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." - ) + 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 @@ -710,95 +519,67 @@ class MapCanvasManager: 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}." - ) + 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}" - ) - - # >>> NUOVO BLOCCO DEBUG <<< + 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 = 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)}" - ) - # >>> FINE NUOVO BLOCCO DEBUG <<< - + # 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) - # >>> NUOVO BLOCCO DEBUG <<< 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)}" - ) - # >>> FINE NUOVO BLOCCO DEBUG <<< + 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}" - ) + logger.error(f"{self.log_prefix} Error deepcopying tracks for render: {e_copy_t}") - target_bbox_to_send = None + # 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_send = copy.deepcopy(ensure_bbox_is_covered_dict) - except Exception as e: - logger.error( - f"{self.log_prefix} Error deepcopying ensure_bbox for render: {e}" - ) - elif self._target_bbox_input_gui: - try: - target_bbox_to_send = copy.deepcopy(self._target_bbox_input_gui) - except Exception as e: - logger.error( - f"{self.log_prefix} Error deepcopying target_bbox_input for render: {e}" - ) + 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_send, + "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, + "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})." - ) + 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." - ) + 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__}" - ) + 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) @@ -812,71 +593,43 @@ class MapCanvasManager: 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 (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.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, - ) + 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." - ) + 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." - ) + 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 - ): + 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, + 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." - ) + 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}" - ) + 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.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 @@ -884,180 +637,89 @@ class MapCanvasManager: 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 - ): + 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 - ): + 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(), - ) + 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, + 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})" - ) + 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, - ) + 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 - ) + 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 - ): + 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, + 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 - ): + 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 - ) + 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( - "" - ) + 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" - ) - ): + 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" - ) - ): + 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") - ): + 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 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})." - ) + 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(), - ) + 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})" - ) + 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 - ) + 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})." - ) + 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, - ) + 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}" - ) + 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()): @@ -1068,347 +730,230 @@ class MapCanvasManager: 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] + 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, - ) + 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}" - ) + 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 - ) + 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 - ) + 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() + 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" - ): + 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, + target_bbox_dict: Dict[str, float], + preserve_current_zoom_if_possible: bool = False, ): - logger.debug( - f"{self.log_prefix} _request_map_render_for_bbox: target_bbox={target_bbox_dict}, preserve_zoom={preserve_current_zoom_if_possible}" - ) + 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"{self.log_prefix} _request_map_render_for_bbox called with invalid/no target BBox. Aborting." - ) - if not self.is_detail_map: - self._display_placeholder_text("Invalid BBox for Map Render") + 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, lon_min, lat_max, lon_max = ( - target_bbox_dict["lat_min"], - target_bbox_dict["lon_min"], - target_bbox_dict["lat_max"], - target_bbox_dict["lon_max"], - ) - view_center_lat, view_center_lon = (lat_min + lat_max) / 2.0, ( - lon_min + lon_max - ) / 2.0 - if lon_min > lon_max: - view_center_lon = (lon_min + (lon_max + 360.0)) / 2.0 - if view_center_lon >= 180.0: - view_center_lon -= 360.0 - zoom_to_use = self._current_zoom_gui - if not preserve_current_zoom_if_possible or zoom_to_use is None: - patch_width_km, patch_height_km = calculate_geographic_bbox_size_km( - (lon_min, lat_min, lon_max, lat_max) + 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 ( - patch_width_km - and patch_height_km - and self.canvas_width > 0 - and self.canvas_height > 0 - ): - zoom_w = calculate_zoom_level_for_geographic_size( - view_center_lat, - patch_width_km * 1000, - self.canvas_width, - self.tile_manager.tile_size, - ) - zoom_h = calculate_zoom_level_for_geographic_size( - view_center_lat, - patch_height_km * 1000, - self.canvas_height, - self.tile_manager.tile_size, - ) - if zoom_w is not None and zoom_h is not None: - 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 - zoom_to_use = new_calc_zoom - logger.info( - f"{self.log_prefix} _request_map_render_for_bbox: Calculated new zoom_to_use: {zoom_to_use}" - ) - else: - logger.warning( - f"{self.log_prefix} _request_map_render_for_bbox: Could not calculate BBox dims or canvas size invalid. Using default zoom." - ) - zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM - 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)) + 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( - view_center_lat, - view_center_lon, - zoom_to_use, - ensure_bbox_is_covered_dict=target_bbox_dict, + 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 + 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 + 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]}...'" - ) + 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." - ) + 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 + 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" - ) + 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), + 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, - ) + 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." - ) + 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." - ) + + 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, + 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" - ): + 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." - ) + 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() + 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 - ): + 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) - ) + 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 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, - ) + 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 - ): + 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 + 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, - ) + 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 - ) + 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, + "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']})" - ) + 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." - ) + 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}" - ) + 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 @@ -1416,326 +961,152 @@ class MapCanvasManager: 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_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 - ), - ) + 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.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, - ) + 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})" - ) + 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." - ) + 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 - ): + 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 likely for the main 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." - ) + 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." - ) + 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 - ) + 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], - } + 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." - ) + 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." - ) + 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}" - ) + 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." - ) + 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 - ) + 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 - ) + 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(): + 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 - ) + 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." - ) + 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." - ) + 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 + 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, - ) + 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." - ) + 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, - ) + 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) - ) + 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) - ) + _, 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_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 + 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 - ) + 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." - ) + 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, - ) + 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) + 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 - ) + 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 @@ -1747,46 +1118,25 @@ class MapCanvasManager: 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 + 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}" - ) + 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.") + 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 + 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.") + 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.") \ No newline at end of file diff --git a/flightmonitor/map/map_utils.py b/flightmonitor/map/map_utils.py index f6878e8..797e408 100644 --- a/flightmonitor/map/map_utils.py +++ b/flightmonitor/map/map_utils.py @@ -1,4 +1,4 @@ -# flightmonitor/map/map_utils.py +# FlightMonitor/map/map_utils.py import logging import math @@ -6,59 +6,55 @@ from typing import Tuple, Optional, List, Set, Dict, Any try: import pyproj - PYPROJ_MODULE_LOCALLY_AVAILABLE = True except ImportError: - pyproj = None + pyproj = None # type: ignore PYPROJ_MODULE_LOCALLY_AVAILABLE = False - import logging - - logging.warning("MapUtils: 'pyproj' not found.") + logging.warning("MapUtils: 'pyproj' not found. Geographic calculations will be impaired.") try: import mercantile - MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError: - mercantile = None + mercantile = None # type: ignore MERCANTILE_MODULE_LOCALLY_AVAILABLE = False - import logging - - logging.warning("MapUtils: 'mercantile' not found.") + logging.warning("MapUtils: 'mercantile' not found. Tile operations will fail.") try: from ..utils.logger import get_logger + logger = get_logger(__name__) +except ImportError: + logger = logging.getLogger(__name__) + if not logger.hasHandlers(): + logging.basicConfig(level=logging.DEBUG) + logger.warning("MapUtils using fallback standard Python logger.") + +try: from . import map_constants except ImportError: - logger = logging.getLogger(__name__) - logger.warning("MapUtils: App logger or map_constants not available.") - + logger.error("MapUtils: map_constants not found. Using mock constants.") class MockMapConstants: COORDINATE_DECIMAL_PLACES = 4 - DMS_DEGREE_SYMBOL = " deg" - DMS_MINUTE_SYMBOL = " min" - DMS_SECOND_SYMBOL = " sec" - - map_constants = MockMapConstants() - - -logger = get_logger(__name__) + DMS_DEGREE_SYMBOL = " deg" + DMS_MINUTE_SYMBOL = "' " + DMS_SECOND_SYMBOL = "''" + MIN_ZOOM_LEVEL = 0 + DEFAULT_MAX_ZOOM_FALLBACK = 20 + map_constants = MockMapConstants() # type: ignore class MapCalculationError(Exception): """Custom exception for map calculations.""" - pass def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool: - """Validate bounding box dict structure and ranges.""" if not isinstance(bbox_dict, dict): - logger.debug("BBox validation failed: not dict.") + # logger.debug("BBox validation failed: not dict.") # Troppo verboso per un check comune return False required_keys = ["lat_min", "lon_min", "lat_max", "lon_max"] if not all(key in bbox_dict for key in required_keys): - logger.debug(f"BBox validation failed: missing keys {required_keys}.") + # logger.debug(f"BBox validation failed: missing keys {required_keys}.") return False try: @@ -67,68 +63,41 @@ def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool: lat_max = float(bbox_dict["lat_max"]) lon_max = float(bbox_dict["lon_max"]) - if not ( - -90.0 <= lat_min <= 90.0 - and -90.0 <= lat_max <= 90.0 - and -180.0 <= lon_min <= 180.0 - and -180.0 <= lon_max <= 180.0 - ): - logger.debug( - f"BBox validation failed: coordinates out of range {bbox_dict}." - ) + if not (-90.0 <= lat_min <= 90.0 and -90.0 <= lat_max <= 90.0 and + -180.0 <= lon_min <= 180.0 and -180.0 <= lon_max <= 180.0): + # logger.debug(f"BBox validation failed: coordinates out of range {bbox_dict}.") return False - if lat_min >= lat_max: - logger.debug( - f"BBox validation failed: lat_min ({lat_min}) >= lat_max ({lat_max})." - ) + if lat_min >= lat_max : + # logger.debug(f"BBox validation failed: lat_min ({lat_min}) >= lat_max ({lat_max}).") return False - # Note: For services not supporting antimeridian crossing, lon_min must be < lon_max. - # Current validation keeps this check for OpenSky compatibility. - if lon_min >= lon_max: - logger.debug( - f"BBox validation failed: lon_min ({lon_min}) >= lon_max ({lon_max})." - ) + if lon_min >= lon_max: # Stretta per OpenSky, ma potrebbe essere valida per mappe che wrappano + # logger.debug(f"BBox validation failed: lon_min ({lon_min}) >= lon_max ({lon_max}).") + return False + if not all(math.isfinite(coord) for coord in [lat_min, lon_min, lat_max, lon_max]): + # logger.debug(f"BBox validation failed: non-finite values {bbox_dict}.") return False - if not all( - math.isfinite(coord) for coord in [lat_min, lon_min, lat_max, lon_max] - ): - logger.debug(f"BBox validation failed: non-finite values {bbox_dict}.") - return False - return True - except (ValueError, TypeError) as e: - logger.debug( - f"BBox validation failed: invalid types/values {bbox_dict}. Error: {e}" - ) + except (ValueError, TypeError): + # logger.debug(f"BBox validation failed: invalid types/values {bbox_dict}. Error: {e}") return False - except Exception as e: - logger.error( - f"Unexpected error validating BBox: {bbox_dict}. Error: {e}", exc_info=True - ) + except Exception as e_val: # Dovrebbe essere raro + logger.error(f"Unexpected error validating BBox: {bbox_dict}. Error: {e_val}", exc_info=True) return False - def get_bounding_box_from_center_size( center_latitude_deg: float, center_longitude_deg: float, area_size_km: float ) -> Optional[Tuple[float, float, float, float]]: - """Calc WGS84 BBox from center and square size (km). Requires pyproj.""" if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: - logger.error("'pyproj' required for BBox from center/size.") + logger.error("'pyproj' required for BBox from center/size. Not available.") return None if not (isinstance(area_size_km, (int, float)) and area_size_km > 0): logger.error(f"Invalid area_size_km: {area_size_km}.") return None - if not ( - isinstance(center_latitude_deg, (int, float)) - and -90.0 <= center_latitude_deg <= 90.0 - ): + if not (isinstance(center_latitude_deg, (int, float)) and -90.0 <= center_latitude_deg <= 90.0): logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}.") return None - if not ( - isinstance(center_longitude_deg, (int, float)) - and -180.0 <= center_longitude_deg <= 180.0 - ): + if not (isinstance(center_longitude_deg, (int, float)) and -180.0 <= center_longitude_deg <= 180.0): logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}.") return None @@ -136,27 +105,25 @@ def get_bounding_box_from_center_size( geodetic_calculator = pyproj.Geod(ellps="WGS84") half_side_m = (area_size_km / 2.0) * 1000.0 - _, north_lat, _ = geodetic_calculator.fwd( - center_longitude_deg, center_latitude_deg, 0.0, half_side_m - ) - _, south_lat, _ = geodetic_calculator.fwd( - center_longitude_deg, center_latitude_deg, 180.0, half_side_m - ) - east_lon, _, _ = geodetic_calculator.fwd( - center_longitude_deg, center_latitude_deg, 90.0, half_side_m - ) - west_lon, _, _ = geodetic_calculator.fwd( - center_longitude_deg, center_latitude_deg, 270.0, half_side_m - ) - + _, north_lat, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 0.0, half_side_m) + _, south_lat, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 180.0, half_side_m) + east_lon, _, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 90.0, half_side_m) + west_lon, _, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 270.0, half_side_m) + north_lat = min(90.0, max(-90.0, north_lat)) south_lat = min(90.0, max(-90.0, south_lat)) + east_lon = (east_lon + 180) % 360 - 180 + west_lon = (west_lon + 180) % 360 - 180 + if east_lon == -180.0 and center_longitude_deg > 0 : east_lon = 180.0 + if west_lon == 180.0 and center_longitude_deg < 0 : west_lon = -180.0 - if not all( - math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat] - ): - logger.error("Calculated BBox contains non-finite values.") + if not all(math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat]): + logger.error("Calculated BBox from center/size contains non-finite values.") return None + + if west_lon > east_lon and area_size_km < 20000: + logger.warning(f"Calculated BBox has west_lon ({west_lon}) > east_lon ({east_lon}) for non-global size. Swapping.") + west_lon, east_lon = east_lon, west_lon return (west_lon, south_lat, east_lon, north_lat) @@ -166,112 +133,81 @@ def get_bounding_box_from_center_size( def get_tile_ranges_for_bbox( - bounding_box_deg: Tuple[float, float, float, float], + bounding_box_deg: Tuple[float, float, float, float], zoom_level: int, ) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]: - """Calc X/Y tile ranges covering BBox at zoom. Requires mercantile.""" if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: - logger.error("'mercantile' required for tile ranges.") + logger.error("'mercantile' required for tile ranges, but not available.") return None west_lon, south_lat, east_lon, north_lat = bounding_box_deg - + clamped_south_lat = max(-85.05112877, min(85.05112877, south_lat)) clamped_north_lat = max(-85.05112877, min(85.05112877, north_lat)) + if clamped_south_lat >= clamped_north_lat: - logger.warning( - f"Clamped lat range invalid: S={clamped_south_lat:.4f}, N={clamped_north_lat:.4f}. Cannot get tile ranges." - ) - return None + logger.warning(f"Clamped latitude range invalid for tile calculation: S={clamped_south_lat:.4f}, N={clamped_north_lat:.4f}.") + center_lat = (south_lat + north_lat) / 2.0 + center_lon = (west_lon + east_lon) / 2.0 + if west_lon > east_lon: center_lon = (west_lon + (east_lon + 360.0)) / 2.0;_ = 0;_ = (0 if center_lon <= 180 else (center_lon := center_lon - 360.0)) + try: + tile = mercantile.tile(center_lon, center_lat, zoom_level) + return ((tile.x, tile.x), (tile.y, tile.y)) + except Exception as e_single_tile: + logger.error(f"Could not get single tile for invalid lat range: {e_single_tile}") + return None if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): - logger.error(f"Invalid zoom level: {zoom_level}.") + logger.error(f"Invalid zoom level for tile calculation: {zoom_level}.") return None try: - # mercantile.tiles handles longitude wrapping automatically - tiles_in_bbox_generator = mercantile.tiles( - west_lon, clamped_south_lat, east_lon, clamped_north_lat, zooms=[zoom_level] - ) + tiles_in_bbox_generator = mercantile.tiles(west_lon, clamped_south_lat, east_lon, clamped_north_lat, zooms=[zoom_level]) list_of_tiles = list(tiles_in_bbox_generator) if not list_of_tiles: - logger.warning( - f"No tiles found by mercantile for BBox at zoom {zoom_level}. Using fallback: tile at BBox center." - ) + logger.warning(f"No tiles found by mercantile for BBox {bounding_box_deg} at zoom {zoom_level}. Attempting center tile.") center_lon = (west_lon + east_lon) / 2.0 - # Handle antimeridian crossing for center longitude - if west_lon > east_lon: - center_lon = (west_lon + east_lon + 360) / 2.0 - if center_lon > 180: - center_lon -= 360 - - center_lat = (south_lat + north_lat) / 2.0 - center_lat = max(-85.05112877, min(85.05112877, center_lat)) - + if west_lon > east_lon: center_lon = (west_lon + (east_lon + 360.0)) / 2.0; _ = (0 if center_lon <= 180 else (center_lon := center_lon - 360.0)) + center_lat = (clamped_south_lat + clamped_north_lat) / 2.0 try: center_tile = mercantile.tile(center_lon, center_lat, zoom_level) min_x, max_x = center_tile.x, center_tile.x min_y, max_y = center_tile.y, center_tile.y - logger.debug( - f"Fallback: Using tile at BBox center ({center_lon:.4f}, {center_lat:.4f}): ({min_x}, {min_y}, {zoom_level})." - ) except Exception as e_center_tile: - logger.error( - f"Fallback failed: Error getting tile for BBox center: {e_center_tile}", - exc_info=True, - ) + logger.error(f"Fallback failed: Error getting tile for BBox center: {e_center_tile}", exc_info=True) return None else: - x_coordinates = [tile.x for tile in list_of_tiles] - y_coordinates = [tile.y for tile in list_of_tiles] - min_x, max_x = min(x_coordinates), max(x_coordinates) - min_y, max_y = min(y_coordinates), max(y_coordinates) - + x_coords = [tile.x for tile in list_of_tiles] + y_coords = [tile.y for tile in list_of_tiles] + min_x, max_x = min(x_coords), max(x_coords) + min_y, max_y = min(y_coords), max(y_coords) + return ((min_x, max_x), (min_y, max_y)) except Exception as e_tile_range_calc: - logger.exception(f"Error calculating tile ranges: {e_tile_range_calc}") + logger.exception(f"Error calculating tile ranges for BBox {bounding_box_deg} at zoom {zoom_level}: {e_tile_range_calc}") return None def calculate_meters_per_pixel( latitude_degrees: float, zoom_level: int, tile_pixel_size: int = 256 ) -> Optional[float]: - """Calc approx ground resolution (m/px) at lat/zoom (Web Mercator).""" try: - if not ( - isinstance(latitude_degrees, (int, float)) - and -90.0 <= latitude_degrees <= 90.0 - ): - logger.warning(f"Invalid latitude for m/px calc: {latitude_degrees}") - return None - if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): - logger.warning(f"Invalid zoom level for m/px calc: {zoom_level}") - return None - if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): - logger.warning(f"Invalid tile_pixel_size for m/px calc: {tile_pixel_size}") - return None + if not (isinstance(latitude_degrees, (int, float)) and -90.0 <= latitude_degrees <= 90.0): return None + if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): return None + if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): return None - EARTH_CIRCUMFERENCE_METERS = 40075016.686 + EARTH_CIRCUMFERENCE_METERS = 40075016.686 clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees)) lat_radians = math.radians(clamped_lat) - + denominator = tile_pixel_size * (2**zoom_level) - if denominator <= 1e-9: - logger.warning("Denominator for m/px calc is zero/near-zero.") - return None - - resolution_m_px = ( - EARTH_CIRCUMFERENCE_METERS * math.cos(lat_radians) - ) / denominator - - if not math.isfinite(resolution_m_px) or resolution_m_px <= 0: - logger.warning( - f"Calculated non-finite/non-positive m/px ({resolution_m_px}) at Lat {latitude_degrees}." - ) - return None + if abs(denominator) < 1e-9: return None + resolution_m_px = (EARTH_CIRCUMFERENCE_METERS * math.cos(lat_radians)) / denominator + + if not math.isfinite(resolution_m_px) or resolution_m_px <= 0: return None return resolution_m_px except Exception as e_mpp_calc: @@ -280,151 +216,79 @@ def calculate_meters_per_pixel( def calculate_geographic_bbox_size_km( - bounding_box_deg: Tuple[float, float, float, float], + bounding_box_deg: Tuple[float, float, float, float], ) -> Optional[Tuple[float, float]]: - """Calc approx W/H (km) of BBox. Requires pyproj.""" if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: - logger.error("'pyproj' required for BBox size.") + logger.error("'pyproj' required for BBox size calculation, but not available.") return None west_lon, south_lat, east_lon, north_lat = bounding_box_deg - if not ( - isinstance(west_lon, (int, float)) - and isinstance(south_lat, (int, float)) - and isinstance(east_lon, (int, float)) - and isinstance(north_lat, (int, float)) - ): - logger.warning(f"Invalid coordinate types for size calc: {bounding_box_deg}.") + if not (isinstance(west_lon, (int, float)) and isinstance(south_lat, (int, float)) and + isinstance(east_lon, (int, float)) and isinstance(north_lat, (int, float))): + logger.warning(f"Invalid coordinate types for BBox size calculation: {bounding_box_deg}.") return None if not (-90.0 <= south_lat <= north_lat <= 90.0): - logger.warning( - f"Invalid lat range for size calc: {south_lat}, {north_lat}. Clamping." - ) - south_lat = max(-90.0, south_lat) - north_lat = min(90.0, north_lat) - if south_lat >= north_lat: - logger.error(f"Invalid lat range after clamping: {south_lat}, {north_lat}.") - return None + logger.warning(f"Invalid latitude range for BBox size calculation: S={south_lat}, N={north_lat}.") + south_lat = max(-90.0, south_lat); north_lat = min(90.0, north_lat) + if south_lat >= north_lat : return None try: geod = pyproj.Geod(ellps="WGS84") - center_lat = (south_lat + north_lat) / 2.0 - center_lat = max(-89.9, min(89.9, center_lat)) - - # Calculate width along the center latitude. Handles antimeridian crossing correctly in pyproj.Geod.inv. - _, _, width_m = geod.inv(west_lon, center_lat, east_lon, center_lat) - width_m = abs(width_m) - - # Calculate height along the center longitude (or 0/180 meridian if BBox crosses). - # For simplicity, using center longitude here. - center_lon_for_height = (west_lon + east_lon) / 2.0 - if west_lon > east_lon: - center_lon_for_height = (west_lon + east_lon + 360) / 2.0 - if center_lon_for_height > 180: - center_lon_for_height -= 360 - - center_lon_for_height = max( - -179.9, min(179.9, center_lon_for_height) - ) # Clamp for geod.inv - - _, _, height_m = geod.inv( - center_lon_for_height, south_lat, center_lon_for_height, north_lat - ) + avg_lon_for_height = (west_lon + east_lon) / 2.0 + if west_lon > east_lon: + avg_lon_for_height = (west_lon + (east_lon + 360.0)) / 2.0 + if avg_lon_for_height > 180.0: avg_lon_for_height -= 360.0 + + _, _, height_m = geod.inv(avg_lon_for_height, south_lat, avg_lon_for_height, north_lat) height_m = abs(height_m) + avg_lat_for_width = (south_lat + north_lat) / 2.0 + _, _, width_m = geod.inv(west_lon, avg_lat_for_width, east_lon, avg_lat_for_width) + width_m = abs(width_m) approx_width_km = width_m / 1000.0 approx_height_km = height_m / 1000.0 - if approx_width_km <= 1e-9 or approx_height_km <= 1e-9: - logger.warning( - f"Calc non-positive W/H for BBox {bounding_box_deg}: ({approx_width_km:.6f}, {approx_height_km:.6f})." - ) - return None + if approx_width_km < 1e-6: approx_width_km = 0.001 + if approx_height_km < 1e-6: approx_height_km = 0.001 + if not all(math.isfinite(val) for val in [approx_width_km, approx_height_km]): - logger.warning( - f"Calc non-finite W/H: ({approx_width_km}, {approx_height_km})." - ) + logger.warning(f"Calculated BBox size has non-finite values: W={approx_width_km} km, H={approx_height_km} km.") return None - + return (approx_width_km, approx_height_km) - except Exception as e_size_calc: - logger.exception(f"Error calculating BBox size: {e_size_calc}") + logger.exception(f"Error calculating BBox size for {bounding_box_deg}: {e_size_calc}") return None def get_hgt_tile_geographic_bounds( lat_coord: int, lon_coord: int ) -> Optional[Tuple[float, float, float, float]]: - """Calc WGS84 BBox (W,S,E,N) for HGT tile integer coords (S, W).""" - if not (isinstance(lat_coord, int) and isinstance(lon_coord, int)): - logger.warning( - f"Invalid input types for HGT bounds: {type(lat_coord)}, {type(lon_coord)}." - ) - return None - # Note: HGT tile coordinates are typically S/W corner integers, e.g., N007E008 is lat_coord=7, lon_coord=8. - # But ranges are [-90, 89] for lat, [-180, 179] for lon based on standard grid. - if not (-90 <= lat_coord <= 89): - logger.warning(f"Lat coord {lat_coord} outside expected HGT range [-90, 89].") - if not (-180 <= lon_coord <= 179): - logger.warning(f"Lon coord {lon_coord} outside expected HGT range [-180, 179].") - - west_lon = float(lon_coord) - south_lat = float(lat_coord) - east_lon = float(lon_coord + 1) - north_lat = float(lat_coord + 1) - - # Clamp to valid WGS84 ranges defensively - west_lon = max(-180.0, west_lon) - south_lat = max(-90.0, south_lat) - east_lon = min(180.0, east_lon) - north_lat = min(90.0, north_lat) - - if south_lat >= north_lat: - logger.warning( - f"Calc HGT bounds have invalid lat range: S={south_lat}, N={north_lat}." - ) - return None - # Longitude wrapping for a single HGT tile shouldn't happen unless input coords are out of range initially. - # The check `west_lon > east_lon` is only meaningful if not crossing antimeridian. - # For HGT tiles, they represent 1x1 degree squares not crossing antimeridian. - # if west_lon > east_lon and abs(west_lon - east_lon) > 1e-9: logger.warning(f"Calc HGT bounds have W > E: W={west_lon}, E={east_lon}."); return None - + if not (isinstance(lat_coord, int) and isinstance(lon_coord, int)): return None + south_lat = float(lat_coord); west_lon = float(lon_coord) + north_lat = south_lat + 1.0; east_lon = west_lon + 1.0 + south_lat = max(-90.0, min(south_lat, 89.0)); north_lat = max(-89.0, min(north_lat, 90.0)) + west_lon = max(-180.0, min(west_lon, 179.0)); east_lon = max(-179.0, min(east_lon, 180.0)) + if south_lat >= north_lat or west_lon >= east_lon: return None return (west_lon, south_lat, east_lon, north_lat) def calculate_zoom_level_for_geographic_size( - latitude_degrees: float, - geographic_height_meters: float, - target_pixel_height: int, - tile_pixel_size: int = 256, + latitude_degrees: float, + geographic_span_meters: float, + target_pixel_span: int, + tile_pixel_size: int = 256 ) -> Optional[int]: - """Calc Web Mercator zoom for geographic height to fit pixel height.""" - if not ( - isinstance(latitude_degrees, (int, float)) and -90.0 <= latitude_degrees <= 90.0 - ): - logger.warning(f"Invalid latitude for zoom calc: {latitude_degrees}") - return None - if not ( - isinstance(geographic_height_meters, (int, float)) - and geographic_height_meters > 0 - ): - logger.warning(f"Invalid geographic_height_meters: {geographic_height_meters}.") - return None - if not (isinstance(target_pixel_height, int) and target_pixel_height > 0): - logger.warning(f"Invalid target_pixel_height: {target_pixel_height}.") - return None - if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): - logger.warning(f"Invalid tile_pixel_size: {tile_pixel_size}.") - return None + if not (isinstance(latitude_degrees, (int, float)) and -90.0 <= latitude_degrees <= 90.0): return None + if not (isinstance(geographic_span_meters, (int, float)) and geographic_span_meters > 0): return None + if not (isinstance(target_pixel_span, int) and target_pixel_span > 0): return None + if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): return None try: - req_res_m_px = geographic_height_meters / target_pixel_height - if req_res_m_px <= 1e-9 or not math.isfinite(req_res_m_px): - logger.warning( - f"Calc non-positive/non-finite required resolution ({req_res_m_px})." - ) + required_resolution_m_px = geographic_span_meters / target_pixel_span + if required_resolution_m_px <= 1e-9 or not math.isfinite(required_resolution_m_px): + logger.warning(f"Calculated non-positive/non-finite required resolution ({required_resolution_m_px}). Cannot determine zoom.") return None EARTH_CIRCUMFERENCE_METERS = 40075016.686 @@ -432,158 +296,104 @@ def calculate_zoom_level_for_geographic_size( lat_radians = math.radians(clamped_lat) cos_lat = math.cos(lat_radians) - if abs(cos_lat) < 1e-9: - logger.warning( - f"Latitude {latitude_degrees} too close to pole for reliable zoom calc." - ) - return None + if abs(cos_lat) < 1e-9: + logger.warning(f"Latitude {latitude_degrees} too close to pole for reliable zoom calculation based on span.") + return map_constants.MIN_ZOOM_LEVEL - if tile_pixel_size <= 0: - logger.error("Invalid tile_pixel_size (<= 0).") - return None - - # res_m_px = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * 2^Z) - # 2^Z = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * req_res_m_px) - # Z = log2(...) - term_for_log = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / ( - tile_pixel_size * req_res_m_px - ) - if term_for_log <= 1e-9: - logger.warning(f"Calc non-positive term for log2 ({term_for_log:.2e}).") - return None + term_for_log = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * required_resolution_m_px) + if term_for_log <= 1e-9: + logger.warning(f"Calculated non-positive term for log2 ({term_for_log:.2e}) in zoom calculation.") + return map_constants.MIN_ZOOM_LEVEL precise_zoom = math.log2(term_for_log) - integer_zoom = int(round(precise_zoom)) - - clamped_zoom = max( - 0, min(integer_zoom, 20) - ) # Clamp to 0-20 range (arbitrary upper cap, services vary) + + # MODIFIED: Usare round() per uno zoom più aderente. Il margine sarà gestito altrove. + integer_zoom = int(round(precise_zoom)) + min_zoom = getattr(map_constants, "MIN_ZOOM_LEVEL", 0) + # Usa un max_zoom più realistico per i servizi di tile comuni come OSM + max_zoom = getattr(map_constants, "DEFAULT_MAX_ZOOM_FALLBACK", 19) # OSM va fino a 19-20 + + clamped_zoom = max(min_zoom, min(integer_zoom, max_zoom)) + + logger.debug(f"CalcZoomForGeoSize: precise={precise_zoom:.2f}, rounded={integer_zoom}, clamped={clamped_zoom} for span {geographic_span_meters}m in {target_pixel_span}px at lat {latitude_degrees:.2f}") return clamped_zoom + except Exception as e_zoom_calc: - logger.exception(f"Error calculating zoom level: {e_zoom_calc}") + logger.exception(f"Error calculating zoom level for geographic size: {e_zoom_calc}") return None def deg_to_dms_string(degree_value: float, coord_type: str) -> str: - """Convert decimal degrees to DMS string.""" if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value): return "N/A" if coord_type.lower() not in ("lat", "lon"): - logger.warning(f"Unknown coord type '{coord_type}'.") return "N/A (Invalid Type)" val_for_suffix = degree_value if coord_type.lower() == "lat": - if not -90.0 <= degree_value <= 90.0: - logger.warning( - f"Lat {degree_value} outside range [-90, 90] for suffix. Clamping." - ) - val_for_suffix = max(-90.0, min(90.0, degree_value)) + if not (-90.0 <= degree_value <= 90.0): val_for_suffix = max(-90.0, min(90.0, degree_value)) elif coord_type.lower() == "lon": val_for_suffix = (degree_value + 180) % 360 - 180 - if val_for_suffix == -180.0 and degree_value != -180.0: - val_for_suffix = 180.0 + if val_for_suffix == -180.0 and degree_value != -180.0: val_for_suffix = 180.0 - abs_deg = abs(degree_value) - degrees = int(abs_deg) - minutes_decimal = (abs_deg - degrees) * 60 + abs_deg_val = abs(degree_value) + degrees = int(abs_deg_val) + minutes_decimal = (abs_deg_val - degrees) * 60.0 minutes = int(minutes_decimal) - seconds = (minutes_decimal - minutes) * 60 - - # Round seconds and carry over if necessary + seconds = (minutes_decimal - minutes) * 60.0 + seconds = round(seconds, 2) - if seconds >= 60.0: # Can happen after rounding, e.g., 59.996 rounds to 60.00 - seconds = 0.0 - minutes += 1 - - if minutes >= 60: - minutes = 0 - degrees += 1 - - # Handle degrees reaching 90 or 180 due to rounding, for suffix logic - if coord_type.lower() == "lat" and degrees > 90: - degrees = 90 - elif coord_type.lower() == "lon" and degrees > 180: - degrees = 180 - + if seconds >= 60.0: seconds = 0.0; minutes += 1 + if minutes >= 60: minutes = 0; degrees +=1 + + if coord_type.lower() == "lat" and degrees > 90: degrees = 90 + elif coord_type.lower() == "lon" and degrees > 180: degrees = 180 + suffix = "" - if coord_type.lower() == "lat": - suffix = "N" if val_for_suffix >= 0 else "S" + if coord_type.lower() == "lat": suffix = "N" if val_for_suffix >= 0 else "S" elif coord_type.lower() == "lon": - suffix = "E" if val_for_suffix >= 0 else "W" - if val_for_suffix == 0.0: - suffix = "" # 0 longitude doesn't need E/W + if abs(val_for_suffix) < 1e-9 : suffix = "" + else: suffix = "E" if val_for_suffix >= 0 else "W" - try: # Use constants for symbols - dms_string = f"{degrees}{map_constants.DMS_DEGREE_SYMBOL} {minutes}{map_constants.DMS_MINUTE_SYMBOL} {seconds:.2f}{map_constants.DMS_SECOND_SYMBOL}" - except AttributeError: - logger.warning("map_constants missing DMS symbols, using plain text.") - dms_string = f"{degrees} Deg {minutes} Min {seconds:.2f} Sec" - except Exception as e: - logger.error(f"Error formatting DMS string: {e}. Plain text.", exc_info=False) - dms_string = f"{degrees} Deg {minutes} Min {seconds:.2f} Sec" - - if suffix: - dms_string += f" {suffix}" - return dms_string + try: + dms_str = (f"{degrees}{map_constants.DMS_DEGREE_SYMBOL} " + f"{minutes:02d}{map_constants.DMS_MINUTE_SYMBOL} " + f"{seconds:05.2f}{map_constants.DMS_SECOND_SYMBOL}") + except AttributeError: + dms_str = f"{degrees}deg {minutes:02d}m {seconds:05.2f}s" + + return f"{dms_str} {suffix}".strip() def get_combined_geographic_bounds_from_tile_info_list( tile_info_list: List[Dict], ) -> Optional[Tuple[float, float, float, float]]: - """Calc min BBox encompassing all tiles in list.""" - if not tile_info_list: - logger.warning("Tile info list empty for combined bounds.") - return None - - min_lon, min_lat, max_lon, max_lat = 180.0, 90.0, -180.0, -90.0 + if not tile_info_list: return None + min_lon_overall, min_lat_overall, max_lon_overall, max_lat_overall = 180.0, 90.0, -180.0, -90.0 initialized = False - for tile_info in tile_info_list: - lat_coord = tile_info.get("latitude_coord") - lon_coord = tile_info.get("longitude_coord") - if lat_coord is None or lon_coord is None: - logger.warning(f"Skipping tile info: missing coords {tile_info}.") - continue - + lat_c = tile_info.get("latitude_coord") + lon_c = tile_info.get("longitude_coord") + if lat_c is None or lon_c is None: continue try: - tile_bounds = get_hgt_tile_geographic_bounds(int(lat_coord), int(lon_coord)) - if tile_bounds: + tile_bounds_wesn = get_hgt_tile_geographic_bounds(int(lat_c), int(lon_c)) + if tile_bounds_wesn: + w, s, e, n = tile_bounds_wesn if not initialized: - min_lon, min_lat, max_lon, max_lat = tile_bounds + min_lon_overall, min_lat_overall, max_lon_overall, max_lat_overall = w, s, e, n initialized = True else: - min_lon = min(min_lon, tile_bounds[0]) - min_lat = min(min_lat, tile_bounds[1]) - max_lon = max(max_lon, tile_bounds[2]) - max_lat = max(max_lat, tile_bounds[3]) - else: - logger.warning( - f"Could not get geo bounds for tile ({lat_coord},{lon_coord}), skipping." - ) - except (ValueError, TypeError) as e: - logger.warning( - f"Invalid coord type in tile info ({lat_coord},{lon_coord}): {e}. Skipping.", - exc_info=False, - ) - except Exception as e: - logger.warning( - f"Error getting bounds for tile ({lat_coord},{lon_coord}): {e}. Skipping.", - exc_info=True, - ) - - if not initialized: - logger.warning("No valid tiles in list for combined bounds.") - return None - if min_lat >= max_lat: - logger.warning(f"Calc invalid combined lat range: S={min_lat}, N={max_lat}.") - return None - # Longitude wrapping in combined bounds from multiple tiles is possible, but harder to represent as simple (W,S,E,N) tuple. - # The current min/max calculation assumes no wrapping. If tiles cross antimeridian, this needs refinement. - # Given the expected use case (local map views), assuming non-wrapping longitude range is usually fine. - - return (min_lon, min_lat, max_lon, max_lat) + min_lon_overall = min(min_lon_overall, w) + min_lat_overall = min(min_lat_overall, s) + max_lon_overall = max(max_lon_overall, e) + max_lat_overall = max(max_lat_overall, n) + except (ValueError, TypeError): continue + except Exception: continue # Catch any other error during tile bound processing + if not initialized: return None + # Basic check for validity, though individual HGT bounds should be valid + if min_lat_overall >= max_lat_overall or min_lon_overall >= max_lon_overall : return None + return (min_lon_overall, min_lat_overall, max_lon_overall, max_lat_overall) def calculate_geographic_bbox_from_pixel_size_and_zoom( @@ -592,304 +402,71 @@ def calculate_geographic_bbox_from_pixel_size_and_zoom( target_pixel_width: int, target_pixel_height: int, zoom_level: int, - tile_pixel_size: int = 256, -) -> Optional[Tuple[float, float, float, float]]: - """Calc geo BBox centered at point corresponding to pixel size/zoom.""" - if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: - logger.error("'pyproj' required for BBox from pixel size/zoom.") - return None + tile_pixel_size: int = 256 +) -> Optional[Tuple[float, float, float, float]]: if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: - logger.error( - "'mercantile' required for BBox from pixel size/zoom (for mercator conversion)." - ) + logger.error("Mercantile missing, cannot calculate BBox from pixel size.") return None - - if not ( - isinstance(center_latitude_deg, (int, float)) - and -90.0 <= center_latitude_deg <= 90.0 - ): - logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}.") - return None - if not ( - isinstance(center_longitude_deg, (int, float)) - and -180.0 <= center_longitude_deg <= 180.0 - ): - logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}.") - return None - if not ( - isinstance(target_pixel_width, int) - and target_pixel_width > 0 - and isinstance(target_pixel_height, int) - and target_pixel_height > 0 - ): - logger.error( - f"Invalid pixel dims ({target_pixel_width}x{target_pixel_height})." - ) - return None - if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): - logger.warning(f"Invalid zoom level: {zoom_level}. Clamping to.") - zoom_level = max(0, min(zoom_level, 25)) # Clamp zoom level - if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): - logger.error(f"Invalid tile_pixel_size: {tile_pixel_size}.") + + if not (isinstance(center_latitude_deg, (int, float)) and -90.0 <= center_latitude_deg <= 90.0 and + isinstance(center_longitude_deg, (int, float)) and -180.0 <= center_longitude_deg <= 180.0 and + isinstance(target_pixel_width, int) and target_pixel_width > 0 and + isinstance(target_pixel_height, int) and target_pixel_height > 0 and + isinstance(zoom_level, int) and 0 <= zoom_level <= 25 and + isinstance(tile_pixel_size, int) and tile_pixel_size > 0): + logger.error(f"Invalid parameters for calculate_geographic_bbox_from_pixel_size_and_zoom: center=({center_latitude_deg},{center_longitude_deg}), target_px=({target_pixel_width}x{target_pixel_height}), zoom={zoom_level}") return None try: - # Convert center geo to mercator pixel at zoom 0 - # Origin (0,0) in mercator pixel space at zoom 0 is the top-left corner of the earth map. - # X increases east, Y increases south. Total size is 256x256 pixels at zoom 0. - # Using the tile_pixel_size gives the size relative to that. - origin_mercator_x_at_z0, origin_mercator_y_at_z0 = mercantile.xy( - -180.0, 85.05112878 - ) # Top-left corner of web mercator projection - center_mercator_x, center_mercator_y = mercantile.xy( - center_longitude_deg, center_latitude_deg - ) + center_merc_x, center_merc_y = mercantile.xy(center_longitude_deg, center_latitude_deg, truncate=False) + resolution_m_px = calculate_meters_per_pixel(center_latitude_deg, zoom_level, tile_pixel_size) + if resolution_m_px is None: return None - # Calculate the pixel coordinates of the center point at the target zoom level - # Pixel coordinates at zoom Z are related to mercator coordinates and tile size. - # Total earth pixel size at zoom Z is tile_pixel_size * 2^Z - total_earth_pixels_z = tile_pixel_size * (2**zoom_level) + half_width_meters = (target_pixel_width / 2.0) * resolution_m_px + half_height_meters = (target_pixel_height / 2.0) * resolution_m_px - # Mercator X/Y range from (-PI * R, PI * R) - # Total width/height in mercator units is 2 * PI * R (~40075016.686 meters) - # Resolution at equator at zoom Z is (2 * PI * R) / (tile_pixel_size * 2^Z) meters/pixel - # A simpler way using mercantile x/y: - # X pixel = (mercator_x - mercator_min_x) / mercator_total_width * total_earth_pixels_z - # Y pixel = (mercator_max_y - mercator_y) / mercator_total_height * total_earth_pixels_z (Y is inverted) + ul_merc_x = center_merc_x - half_width_meters + ul_merc_y = center_merc_y + half_height_meters + lr_merc_x = center_merc_x + half_width_meters + lr_merc_y = center_merc_y - half_height_meters - # mercantile.xy returns coordinates in "Web Mercator" units (meters), relative to the origin of the projection. - # The origin of the projection is at (-180, +85.0511) geographic, corresponding to mercator (-20037508.34, 20037508.34). - # The full extent is 2 * 20037508.34 meters in both directions. - mercator_extent_meters = ( - 20037508.34 * 2 - ) # Total width/height of the Mercator projection in meters + west_lon, north_lat = mercantile.lnglat(ul_merc_x, ul_merc_y, truncate=False) + east_lon, south_lat = mercantile.lnglat(lr_merc_x, lr_merc_y, truncate=False) + + MAX_MERC_LAT = 85.05112878 + north_lat = max(-MAX_MERC_LAT, min(MAX_MERC_LAT, north_lat)) + south_lat = max(-MAX_MERC_LAT, min(MAX_MERC_LAT, south_lat)) + + west_lon = (west_lon + 180) % 360 - 180 + east_lon = (east_lon + 180) % 360 - 180 + if west_lon == 180 and center_longitude_deg < 0: west_lon = -180.0 + if east_lon == -180 and center_longitude_deg > 0: east_lon = 180.0 - center_pixel_x_z0 = ( - (center_mercator_x - (-mercator_extent_meters / 2)) - / mercator_extent_meters - * tile_pixel_size - ) # Pixel relative to 256x256 tile at z0 - center_pixel_y_z0 = ( - ((mercator_extent_meters / 2) - center_mercator_y) - / mercator_extent_meters - * tile_pixel_size - ) # Pixel relative to 256x256 tile at z0 (inverted Y) - - # Pixel coordinates at zoom Z are pixel_at_z0 * 2^Z - center_pixel_x_z = center_pixel_x_z0 * (2**zoom_level) - center_pixel_y_z = center_pixel_y_z0 * (2**zoom_level) - - # The target BBox pixel center is (target_pixel_width/2, target_pixel_height/2) relative to its top-left corner. - # The top-left pixel of the target BBox corresponds to a mercator coordinate. - # Let BBox_center_pixel_x, BBox_center_pixel_y be the center pixel of the *rendered* BBox (at the center of the canvas). - # The top-left pixel of the BBox is (BBox_center_pixel_x - target_pixel_width/2, BBox_center_pixel_y - target_pixel_height/2) - # The bottom-right pixel is (BBox_center_pixel_x + target_pixel_width/2, BBox_center_pixel_y + target_pixel_height/2) - # Assuming the stitched image is centered on the geo coordinate center_latitude_deg, center_longitude_deg, - # the center of the stitched image is at pixel (img_width/2, img_height/2). - # The pixel coordinates relative to the *top-left of the entire stitched map at zoom Z* - # that corresponds to the target BBox's top-left corner would be: - # ul_pixel_x_z = center_pixel_x_z - target_pixel_width / 2.0 - # ul_pixel_y_z = center_pixel_y_z - target_pixel_height / 2.0 - # lr_pixel_x_z = center_pixel_x_z + target_pixel_width / 2.0 - # lr_pixel_y_z = center_pixel_y_z + target_pixel_height / 2.0 - - # Convert these pixel coordinates back to mercator, then to geo. - # mercator_x = mercator_min_x + (pixel_x_z / total_earth_pixels_z) * mercator_total_width - # mercator_y = mercator_max_y - (pixel_y_z / total_earth_pixels_z) * mercator_total_height # Inverted Y - - ul_pixel_x_z = center_pixel_x_z - target_pixel_width / 2.0 - ul_pixel_y_z = center_pixel_y_z - target_pixel_height / 2.0 - lr_pixel_x_z = center_pixel_x_z + target_pixel_width / 2.0 - lr_pixel_y_z = center_pixel_y_z + target_pixel_height / 2.0 - - ul_mercator_x = (-mercator_extent_meters / 2) + ( - ul_pixel_x_z / total_earth_pixels_z - ) * mercator_extent_meters - ul_mercator_y = (mercator_extent_meters / 2) - ( - ul_pixel_y_z / total_earth_pixels_z - ) * mercator_extent_meters # Inverted Y - - lr_mercator_x = (-mercator_extent_meters / 2) + ( - lr_pixel_x_z / total_earth_pixels_z - ) * mercator_extent_meters - lr_mercator_y = (mercator_extent_meters / 2) - ( - lr_pixel_y_z / total_earth_pixels_z - ) * mercator_extent_meters # Inverted Y - - # Convert mercator corners back to geo (lon, lat) - ul_lon, ul_lat = mercantile.lnglat(ul_mercator_x, ul_mercator_y) - lr_lon, lr_lat = mercantile.lnglat(lr_mercator_x, lr_mercator_y) - - # The bounding box is (west_lon, south_lat, east_lon, north_lat) - west_lon = ul_lon - north_lat = ul_lat - east_lon = lr_lon - south_lat = lr_lat - - # Ensure latitudes are within valid range for WGS84 - north_lat = min(90.0, max(-90.0, north_lat)) - south_lat = min(90.0, max(-90.0, south_lat)) - - # Handle longitude wrapping (e.g., if the canvas width spans the antimeridian) - # mercantile.lnglat handles the -180/180 wrap, but the calculated east_lon might be < west_lon. - # For consistency, ensure west_lon <= east_lon if not crossing antimeridian. - # If it crosses, the standard (W,S,E,N) tuple doesn't represent it well. - # Given our assumption for OpenSky, we expect non-wrapping BBoxes. - # If lr_lon < ul_lon, it might indicate wrapping, but for a single canvas view, - # we usually expect ul_lon < lr_lon unless the canvas is extremely wide and centered on antimeridian. - # For this calculation, we just return the calculated ul_lon and lr_lon as west and east. - - if abs(west_lon - east_lon) < 1e-9 or abs(south_lat - north_lat) < 1e-9: - logger.warning("Calc zero-size geo BBox from pixel size/zoom.") - return None - if not all( - math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat] - ): - logger.error( - "Calc geo BBox from pixel size/zoom contains non-finite values." - ) - return None + if abs(west_lon - east_lon) < 1e-9 or abs(south_lat - north_lat) < 1e-9 : return None + if not all(math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat]): return None return (west_lon, south_lat, east_lon, north_lat) - except Exception as e_bbox_calc: - logger.exception( - f"Error calculating geo BBox from pixel size/zoom: {e_bbox_calc}" - ) + logger.exception(f"Error calculating geo BBox from pixel size/zoom: {e_bbox_calc}") return None - -def calculate_inner_geographic_bbox( - outer_bbox_deg: Tuple[float, float, float, float], - reduction_distance_km: float, -) -> Optional[Tuple[float, float, float, float]]: - """Calc inner geo BBox by reducing outer BBox by distance (km). Requires pyproj.""" - if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: - logger.error("'pyproj' required for inner BBox.") - return None - - west_lon, south_lat, east_lon, north_lat = outer_bbox_deg - reduct_dist_m = reduction_distance_km * 1000.0 - - if not ( - isinstance(reduction_distance_km, (int, float)) and reduction_distance_km >= 0 - ): - logger.error(f"Invalid reduct_dist_km: {reduction_distance_km}.") - return None - if not ( - isinstance(west_lon, (int, float)) - and isinstance(south_lat, (int, float)) - and isinstance(east_lon, (int, float)) - and isinstance(north_lat, (int, float)) - ): - logger.warning(f"Invalid coord types for outer_bbox: {outer_bbox_deg}.") - return None - if not (-90.0 <= south_lat <= north_lat <= 90.0): - logger.warning(f"Invalid lat range in outer_bbox: {south_lat}, {north_lat}.") - return None - if not (-180.0 <= west_lon <= 180.0 and -180.0 <= east_lon <= 180.0): - logger.warning(f"Invalid lon range in outer_bbox: {west_lon}, {east_lon}.") - return None # Assuming non-wrapping input BBox - - if reduction_distance_km == 0: - return outer_bbox_deg - - try: - geod = pyproj.Geod(ellps="WGS84") - outer_size_km = calculate_geographic_bbox_size_km(outer_bbox_deg) - if outer_size_km is None: - logger.error("Could not calc outer BBox size for inner BBox.") - return None - - outer_width_km, outer_height_km = outer_size_km - inner_width_km = outer_width_km - (2 * reduction_distance_km) - inner_height_km = outer_height_km - (2 * reduction_distance_km) - - if inner_width_km <= 1e-9 or inner_height_km <= 1e-9: - logger.warning( - f"Reduct dist {reduction_distance_km}km too large. Inner BBox size {inner_width_km:.2f}x{inner_height_km:.2f}km is zero or negative." - ) - return None - - center_lat = (south_lat + north_lat) / 2.0 - center_lon = (west_lon + east_lon) / 2.0 - if west_lon > east_lon: - center_lon = (west_lon + east_lon + 360) / 2.0 - if center_lon > 180: - center_lon -= 360 - - center_lat_clamped = max(-89.9, min(89.9, center_lat)) - center_lon_clamped = max( - -179.9, min(179.9, center_lon) - ) # Clamp center_lon for fwd if needed - - half_inner_width_m = inner_width_km * 500.0 - half_inner_height_m = inner_height_km * 500.0 - - # Calculate inner BBox corners by moving from the center - _, inner_north_lat, _ = geod.fwd( - center_lon_clamped, center_lat_clamped, 0.0, half_inner_height_m - ) - _, inner_south_lat, _ = geod.fwd( - center_lon_clamped, center_lat_clamped, 180.0, half_inner_height_m - ) - inner_east_lon, _, _ = geod.fwd( - center_lon_clamped, center_lat_clamped, 90.0, half_inner_width_m - ) - inner_west_lon, _, _ = geod.fwd( - center_lon_clamped, center_lat_clamped, 270.0, half_inner_width_m - ) - - inner_north_lat = min(90.0, max(-90.0, inner_north_lat)) - inner_south_lat = min(90.0, max(-90.0, inner_south_lat)) - - if ( - abs(inner_west_lon - inner_east_lon) < 1e-9 - or abs(inner_south_lat - inner_north_lat) < 1e-9 - ): - logger.warning("Calc zero-size inner geo BBox.") - return None - if not all( - math.isfinite(coord) - for coord in [ - inner_west_lon, - inner_south_lat, - inner_east_lon, - inner_north_lat, - ] - ): - logger.error("Calc inner geo BBox contains non-finite values.") - return None - - return (inner_west_lon, inner_south_lat, inner_east_lon, inner_north_lat) - - except Exception as e_inner_bbox_calc: - logger.exception(f"Error calculating inner geo BBox: {e_inner_bbox_calc}") - return None - - def _pixel_to_geo( canvas_x: int, canvas_y: int, - current_map_geo_bounds: Optional[Tuple[float, float, float, float]], - current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height) -) -> Tuple[Optional[float], Optional[float]]: - """ - Convert canvas pixel (x, y) to geo (lon, lat). Requires mercantile. - Uses current map geo bounds and pixel shape for context. - """ + current_map_geo_bounds: Optional[Tuple[float, float, float, float]], + current_stitched_map_pixel_shape: Optional[Tuple[int, int]], +) -> Tuple[Optional[float], Optional[float]]: if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: - logger.error("_pixel_to_geo: Mercantile missing.") + logger.error("_pixel_to_geo: Mercantile missing or not available.") return None, None - if current_map_geo_bounds is None: - logger.warning("_pixel_to_geo: Map geo bounds missing.") + if current_map_geo_bounds is None or len(current_map_geo_bounds) != 4: + logger.warning("_pixel_to_geo: Map geo bounds missing or invalid.") + return None, None + if current_stitched_map_pixel_shape is None or len(current_stitched_map_pixel_shape) != 2: + logger.warning("_pixel_to_geo: Map pixel shape missing or invalid.") return None, None - if current_stitched_map_pixel_shape is None: - logger.warning("_pixel_to_geo: Map pixel shape missing.") - return None img_w, img_h = current_stitched_map_pixel_shape - if img_w <= 0 or img_h <= 0: logger.warning(f"_pixel_to_geo: Map pixel dims invalid ({img_w}x{img_h}).") return None, None @@ -897,53 +474,35 @@ def _pixel_to_geo( map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds try: - map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) - map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) + map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat, truncate=False) + map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat, truncate=False) - total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) - total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) + total_map_width_merc = map_lr_merc_x - map_ul_merc_x + total_map_height_merc = map_ul_merc_y - map_lr_merc_y - if total_map_width_merc <= 1e-9 or total_map_height_merc <= 1e-9: - logger.warning( - f"_pixel_to_geo: Map mercator dims zero/near-zero ({total_map_width_merc:.2e}x{total_map_height_merc:.2e})." - ) + if map_east_lon < map_west_lon: + map_east_lon_equiv = map_east_lon + 360.0 + temp_lr_merc_x, _ = mercantile.xy(map_east_lon_equiv, map_south_lat, truncate=False) + total_map_width_merc = temp_lr_merc_x - map_ul_merc_x + + if abs(total_map_width_merc) < 1e-9 or abs(total_map_height_merc) < 1e-9: + logger.warning(f"_pixel_to_geo: Map mercator dims zero/near-zero ({total_map_width_merc:.2e}x{total_map_height_merc:.2e}).") return None, None - # Calculate the pixel coordinates relative to the top-left of the full mercator projection at the current zoom. - # Need the zoom level to know the total pixel size of the earth at that zoom. - # This function doesn't receive the zoom level directly, but it's used by MapCanvasManager which has it. - # However, the conversion from mercator to pixel relative to the *stitched image* only needs the stitched image geo bounds and pixel size. - - # Convert pixel position (canvas_x, canvas_y) relative to stitched image to mercator coordinate. - # relative_x_on_map_pixels is 0 at the left edge of the stitched image, 1 at the right edge. relative_x_on_map_pixels = canvas_x / img_w - # relative_y_on_map_pixels is 0 at the top edge, 1 at the bottom. relative_y_on_map_pixels = canvas_y / img_h + + target_merc_x = map_ul_merc_x + (relative_x_on_map_pixels * total_map_width_merc) + target_merc_y = map_ul_merc_y - (relative_y_on_map_pixels * total_map_height_merc) - # Convert relative pixel position to relative mercator position - # X: 0.0 at map_west_lon (ul_merc_x), 1.0 at map_east_lon (lr_merc_x) - # Y: 0.0 at map_north_lat (ul_merc_y), 1.0 at map_south_lat (lr_merc_y) - target_merc_x = map_ul_merc_x + ( - relative_x_on_map_pixels * total_map_width_merc - ) - target_merc_y = map_ul_merc_y - ( - relative_y_on_map_pixels * total_map_height_merc - ) # Y is inverted relative to map_ul_merc_y - - # Convert mercator back to geo (lon, lat) - clicked_lon, clicked_lat = mercantile.lnglat(target_merc_x, target_merc_y) - - # Clamp latitude to Mercator limits for safety - MAX_MERCATOR_LATITUDE = 85.05112878 - clicked_lat = max( - -MAX_MERCATOR_LATITUDE, min(MAX_MERCATOR_LATITUDE, clicked_lat) - ) + clicked_lon, clicked_lat = mercantile.lnglat(target_merc_x, target_merc_y, truncate=False) + + MAX_MERCATOR_LATITUDE = 85.05112878 + clicked_lat = max(-MAX_MERCATOR_LATITUDE, min(MAX_MERCATOR_LATITUDE, clicked_lat)) + clicked_lon = (clicked_lon + 180) % 360 - 180 + if clicked_lon == -180.0 and target_merc_x > 0: clicked_lon = 180.0 return clicked_lon, clicked_lat - except Exception as e_pixel_to_geo: - logger.error( - f"Error converting pixel ({canvas_x}, {canvas_y}) to geo. Error: {e_pixel_to_geo}", - exc_info=True, - ) - return None, None + logger.error(f"Error converting pixel ({canvas_x}, {canvas_y}) to geo. Error: {e_pixel_to_geo}", exc_info=True) + return None, None \ No newline at end of file