# flightmonitor/map/map_canvas_manager.py """Manages map display and interaction on a Tkinter canvas.""" import tkinter as tk try: # MODIFIED: Import Image, ImageTk, ImageDraw, ImageFont explicitly from PIL. # WHY: Ensure all required components from Pillow are checked for availability. # ImageFont is needed for drawing text labels and placeholders. # HOW: Listed components in the import. from PIL import Image, ImageTk, ImageDraw, ImageFont # MODIFIED: Define PIL_LIB_AVAILABLE_DRAWING as True in the try block. # WHY: The variable needs to be defined here if the import succeeds. # HOW: Assigned True. PIL_LIB_AVAILABLE_DRAWING = True except ImportError as e_pil: Image = None ImageTk = None # Add ImageTk to fallbacks ImageDraw = None ImageFont = None # Add ImageFont to fallbacks # MODIFIED: Define PIL_LIB_AVAILABLE_DRAWING as False in the except block. # WHY: The variable needs to be defined here if the import fails. # HOW: Assigned False. PIL_LIB_AVAILABLE_DRAWING = False import logging logging.error(f"MapCanvasManager: Pillow (Image, ImageTk, ImageDraw, ImageFont) not found: {e_pil}. Map disabled.") try: import pyproj PYPROJ_MODULE_LOCALLY_AVAILABLE = True except ImportError as e_pyproj: pyproj = None # MODIFIED: Ensure logging is imported for fallback messages. # WHY: Log error even if pyproj fails. # HOW: Added import. import logging logging.warning(f"MapCanvasManager: 'pyproj' not found: {e_pyproj}. Geographic calculations impaired.") try: import mercantile MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError as e_mercantile: mercantile = None # MODIFIED: Ensure logging is imported for fallback messages. # WHY: Log error even if mercantile fails. # HOW: Added import. import logging logging.error(f"MapCanvasManager: 'mercantile' not found: {e_mercantile}. Tile and Mercator conversions impaired.") import math from typing import Optional, Tuple, List, Dict, Any # FlightMonitor imports from flightmonitor.map import map_constants from flightmonitor.data import ( config as fm_config, # Alias for application configuration ) from flightmonitor.data.common_models import CanonicalFlightState # Map module imports from flightmonitor.map.map_services import BaseMapService, OpenStreetMapService from flightmonitor.map.map_manager import MapTileManager # MODIFIED: Import the revised map utility functions. # WHY: Use the centralized and corrected utilities for calculations and conversions. # HOW: Updated the import list. from .map_utils import ( get_tile_ranges_for_bbox, # Used to find tiles for a given geo BBox calculate_geographic_bbox_size_km, # Used to estimate BBox size in km calculate_geographic_bbox_from_pixel_size_and_zoom, # Used to find geo BBox covered by canvas view # MODIFIED: Removed calculate_zoom_level_for_geographic_size - no longer needed/used. # WHY: The new fitting logic doesn't use this specific function. # HOW: Removed from import. # calculate_zoom_level_for_geographic_size, deg_to_dms_string, # Used for info panel display _is_valid_bbox_dict, # Internal utility for BBox validation # MODIFIED: Import the corrected geo_to_pixel and pixel_to_geo functions. # WHY: Use the centralized, accurate conversion functions. # HOW: Added functions to the import list. geo_to_pixel, # Used to convert geo coords to pixel coords on the map image pixel_to_geo, # Used to convert pixel coords on the map image to geo coords MAX_LATITUDE_MERCATOR, # Import the max latitude for Mercator EARTH_RADIUS_METERS # Import Earth radius for Mercator scale ) from . import map_drawing # Drawing utilities for overlays # Logger try: from ..utils.logger import get_logger except ImportError: # MODIFIED: Ensure logging is imported for fallback. # WHY: Need `logging.getLogger` if `get_logger` fails. # HOW: Added import. import logging # MODIFIED: Assign `logging.getLogger` as the fallback for `get_logger`. # WHY: Allows the code to call `get_logger` even if the app logger is missing. # HOW: Assigned. get_logger = logging.getLogger # Fallback logger getter logging.warning("MapCanvasManager using fallback logger.") # Use the application logger if available, otherwise the fallback try: logger = get_logger(__name__) except NameError: # Logger was already created by fallback, just use it pass # Hardcoded fallback for canvas size CANVAS_SIZE_HARD_FALLBACK_PX = 800 # Hardcoded fallback for map tile cache directory MAP_TILE_CACHE_DIR_HARD_FALLBACK = "flightmonitor_tile_cache_fallback" # Delay (in milliseconds) before processing a canvas resize event. # Used for debouncing continuous resize events. RESIZE_DEBOUNCE_DELAY_MS = 50 class MapCanvasManager: """Manages map display and interaction on a Tkinter canvas.""" def __init__( self, app_controller: Any, tk_canvas: tk.Canvas, initial_bbox_dict: Optional[Dict[str, float]] = None, # Made optional ): logger.info("Initializing MapCanvasManager...") # MODIFIED: Check for ImageDraw and ImageFont availability explicitly. # WHY: These are needed for drawing overlays and placeholder text. # HOW: Added ImageDraw and ImageFont to the check. # MODIFIED: Check for pyproj availability. # WHY: Pyproj is needed for some calculations like BBox size and fitting. # HOW: Added check. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING is checked here instead of PIL_IMAGE_LIB_AVAILABLE. # WHY: Use the variable defined by the try/except block above for the PIL check. # HOW: Replaced variable name. if not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: critical_msg = "MapCanvasManager critical dependencies missing: Pillow (Image, ImageTk, ImageDraw, ImageFont), Mercantile, or Pyproj. Map disabled." logger.critical(critical_msg) raise ImportError(critical_msg) self.app_controller = app_controller # MODIFIED: Check if tk_canvas is a valid Canvas widget before storing. # WHY: Prevent errors later if the provided canvas is not valid. # HOW: Added isinstance and winfo_exists checks. if not isinstance(tk_canvas, tk.Canvas) or not tk_canvas.winfo_exists(): error_msg = "Invalid or non-existent Tkinter canvas widget provided to MapCanvasManager." logger.critical(error_msg) raise ValueError(error_msg) self.canvas = tk_canvas # Get initial canvas dimensions. Fallbacks are used if winfo_* return invalid values initially. self.canvas_width = self.canvas.winfo_width() if self.canvas_width <= 1: # MODIFIED: Ensure fallback canvas size is positive. # WHY: Dimensions must be positive. # HOW: Added max(1, ...). self.canvas_width = max(1, getattr(fm_config, 'DEFAULT_CANVAS_WIDTH', CANVAS_SIZE_HARD_FALLBACK_PX)) logger.debug(f"Canvas width invalid ({self.canvas_width}). Using fallback: {self.canvas_width}") self.canvas_height = self.canvas.winfo_height() if self.canvas_height <= 1: # MODIFIED: Ensure fallback canvas size is positive. # WHY: Dimensions must be positive. # HOW: Added max(1, ...). self.canvas_height = max(1, getattr(fm_config, 'DEFAULT_CANVAS_HEIGHT', CANVAS_SIZE_HARD_FALLBACK_PX)) logger.debug(f"Canvas height invalid ({self.canvas_height}). Using fallback: {self.canvas_height}") # Final check on initial dimensions after fallbacks if self.canvas_width <= 0 or self.canvas_height <= 0: logger.critical(f"MapCanvasManager init with invalid canvas dims ({self.canvas_width}x{self.canvas_height}) after fallbacks.") raise ValueError("Invalid canvas dimensions.") # State variables for the currently DISPLAYED map view # These represent the center, zoom, and geo bounds of the *map image* currently drawn on the canvas. self._current_center_lat: Optional[float] = None self._current_center_lon: Optional[float] = None self._current_zoom: int = map_constants.DEFAULT_INITIAL_ZOOM # Start with a default zoom self._current_map_geo_bounds: Optional[Tuple[float, float, float, float]] = None # Geographic bounds of the *stitched image* on display # PIL image and PhotoImage for the base map display self._map_pil_image: Optional[Image.Image] = None self._map_photo_image: Optional[ImageTk.PhotoImage] = None self._canvas_image_id: Optional[int] = None # Canvas item ID for the base map image # Map service and tile manager self.map_service: BaseMapService = OpenStreetMapService() # Using OSM by default cache_dir = getattr(fm_config, "MAP_TILE_CACHE_DIR", MAP_TILE_CACHE_DIR_HARD_FALLBACK) # MODIFIED: Check if MapTileManager class is available before instantiating. # WHY: Avoid AttributeError if the class failed to import (although the checks above cover this too). # HOW: Added check. if MapTileManager is not None: self.tile_manager: MapTileManager = MapTileManager( map_service=self.map_service, cache_root_directory=cache_dir, tile_pixel_size=self.map_service.tile_size, ) logger.info(f"MapTileManager initialized for service '{self.tile_manager.service_identifier_name}'.") else: logger.error("MapTileManager class is None. Cannot initialize tile manager.") # This should be caught by the dependency check at the start, but defensive. raise ImportError("MapTileManager class not available.") # MODIFIED: Added _target_bbox_input here to store the BBox from GUI inputs (monitoring area). # WHY: This BBox is used specifically for drawing the OUTLINE on the map, # NOT for changing the map view by default (that's handled by fit_view_to_bbox). # HOW: Added attribute, initialized to None. This will be set by the Controller/MainWindow. self._target_bbox_input: Optional[Dict[str, float]] = None # BBox from GUI inputs / Monitoring Area # MODIFIED: Removed _active_api_bbox_for_flights attribute. # WHY: Redundant. The monitoring BBox is passed directly from the controller when needed # (currently, passed as context to display_flights_on_canvas, although not used by map_drawing). # The BBox outline to draw is stored in _target_bbox_input. # HOW: Removed the attribute. self._current_flights_to_display: List[CanonicalFlightState] = [] # Flights to draw on the map # MODIFIED: Added instance variable for debounce job ID (used for both resize and zoom). # WHY: To store the ID returned by canvas.after() for the scheduled redraw jobs, # allowing us to cancel them if new events occur before they fire. # HOW: Added attribute initialized to None. self._debounce_job_id: Optional[str] = None # Use a single ID for all debounced redraws # Set initial Map View based on initial_bbox_dict or default if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict): logger.debug(f"Setting initial map view for BBox: {initial_bbox_dict}") # MODIFIED: Call _fit_map_view_to_bbox for initial view fitting. # WHY: Use the new internal method for fitting the view to a BBox. # HOW: Changed method call. self._fit_map_view_to_bbox(initial_bbox_dict) else: logger.warning(f"Invalid/None initial_bbox_dict: {initial_bbox_dict}. Using default view parameters (World, Zoom {map_constants.DEFAULT_INITIAL_ZOOM}).") # Set default center/zoom and draw the default view (World at zoom 0/1, but we start at DEFAULT_INITIAL_ZOOM) self._current_center_lat = 0.0 # Default center latitude self._current_center_lon = 0.0 # Default center longitude self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM # Default initial zoom level logger.debug(f"Set default view center ({self._current_center_lat}, {self._current_center_lon}) and zoom {self._current_zoom}.") # Redraw the map with the default view parameters # MODIFIED: Call recenter_and_redraw for the initial default view. # WHY: Use the standard redraw method for the initial display. # HOW: Added call. self.recenter_and_redraw(self._current_center_lat, self._current_center_lon, self._current_zoom) # Setup event bindings for user interaction self._setup_event_bindings() logger.info(f"MapCanvasManager initialized for canvas size {self.canvas_width}x{self.canvas_height}.") def _setup_event_bindings(self): """Set up Tkinter event bindings for map interaction.""" # MODIFIED: Check if canvas object exists before binding. # WHY: Prevent AttributeError if canvas creation failed. # HOW: Added check. if hasattr(self, 'canvas') and self.canvas is not None and self.canvas.winfo_exists(): self.canvas.bind("", self._on_canvas_resize) self.canvas.bind("", self._on_mouse_wheel_windows_macos) self.canvas.bind("", self._on_mouse_wheel_linux) # Scroll up on Linux self.canvas.bind("", self._on_mouse_wheel_linux) # Scroll down on Linux self.canvas.bind("", self._on_mouse_button_press) # Left click press for drag start self.canvas.bind("", self._on_mouse_drag) # Mouse motion while left button is held self.canvas.bind("", self._on_mouse_button_release) # Left click release to finalize drag self.canvas.bind("", self._on_right_click) # Right click for context menu self._drag_start_x_canvas: Optional[int] = None # Canvas X coord at drag start self._drag_start_y_canvas: Optional[int] = None # Canvas Y coord at drag start self._drag_start_center_lon: Optional[float] = None # Map center Lon at drag start self._drag_start_center_lat: Optional[float] = None # Map center Lat at drag start self._is_dragging: bool = False # Flag to indicate if a drag gesture is in progress else: logger.error("Canvas object is None or non-existent. Cannot set up event bindings.") # MODIFIED: Implement debouncing for the resize handler. # WHY: Avoid continuous map redraws during window resizing. # HOW: Use canvas.after_cancel and canvas.after to schedule the actual redraw logic # with a delay, cancelling previous scheduled jobs if new events arrive. def _on_canvas_resize(self, event: tk.Event): """Handle canvas resize event with debouncing.""" # MODIFIED: Check if canvas exists before proceeding. # WHY: Prevent errors if canvas is destroyed during resize event processing. # HOW: Added check. if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): return new_width = event.width new_height = event.height # Check for valid non-zero dimensions and if dimensions have changed # Also check if the canvas widget itself is still valid. if new_width > 1 and new_height > 1 and (self.canvas_width != new_width or self.canvas_height != new_height): # Dimensions changed. Store the new dimensions temporarily. # The actual instance attributes (self.canvas_width/height) will be updated # in _perform_resize_redraw when the debounced function fires. # logger.debug(f"Configure event: New dims {new_width}x{new_height}, current {self.canvas_width}x{self.canvas_height}") # Too verbose # If a previous debounced job (resize or zoom) is scheduled, cancel it. # MODIFIED: Use _debounce_job_id for consistency. # WHY: This ID is used for both resize and zoom debounce jobs. # HOW: Renamed variable. if self._debounce_job_id: try: self.canvas.after_cancel(self._debounce_job_id) # MODIFIED: Clear the stored ID regardless of cancel success TclError. # WHY: Prevent trying to cancel an invalid ID later. # HOW: Assigned None in finally block or after successful cancel. # logger.debug("Cancelled previous debounced redraw job.") # Too verbose except tk.TclError: # This happens if the job already fired or the canvas is gone # logger.debug("TclError cancelling debounced redraw job.") # Too verbose pass # Ignore TclError during cancel except Exception as e: logger.error(f"Error cancelling debounced redraw job: {e}", exc_info=False) finally: self._debounce_job_id = None # Ensure ID is cleared # Schedule the actual redraw to happen after a delay. # Pass the new width and height to the scheduled function. # MODIFIED: Use _debounce_job_id for consistency. # WHY: Schedule the new job. # HOW: Assigned to _debounce_job_id. self._debounce_job_id = self.canvas.after( RESIZE_DEBOUNCE_DELAY_MS, # Use the same debounce delay for both resize and zoom self._perform_resize_redraw, new_width, # Pass the new dimensions new_height # Pass the new dimensions ) # logger.debug(f"Scheduled resize redraw job (ID: {self._debounce_job_id}) for {RESIZE_DEBOUNCE_DELAY_MS}ms.") # Too verbose # MODIFIED: Created a new method to contain the actual resize redrawing logic. # WHY: This method is called by canvas.after() after the debounce delay. # HOW: Defined the new method. def _perform_resize_redraw(self, width: int, height: int): """Performs the map redraw after a debounced resize event.""" # Clear the job ID since the job is now executing. # MODIFIED: Use _debounce_job_id for consistency. # WHY: Clear the ID used for scheduling. # HOW: Assigned None to _debounce_job_id. self._debounce_job_id = None # Ensure the canvas is still valid before proceeding with redraw. if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): # logger.debug("Canvas is gone, cannot perform resize redraw.") # Too verbose return # Exit if canvas is destroyed # Update the instance's recorded canvas dimensions. # This happens *only* when the debounced function fires. logger.info(f"Performing debounced resize redraw for dimensions {width}x{height}.") self.canvas_width = width self.canvas_height = height # Redraw centered at the *current* geographic center with the *current* zoom. # The recenter_and_redraw method will use the new canvas dimensions (from winfo_*) # to determine which tiles to fetch to cover the new display area. # MODIFIED: Simplify resize logic. Just recenter at the current geographic center with the current zoom. # The map should NOT refit to the target BBox on every resize unless the user explicitly centers it again. # WHY: Maintain user's current view on resize, consistent with general map behavior. # HOW: Changed the logic to always call recenter_and_redraw with current center/zoom. # MODIFIED: Check for necessary dependencies before proceeding with redraw. # WHY: Avoid errors if libs are missing. # HOW: Added check. # MODIFIED: Check for Pyproj as well, needed by recenter_and_redraw utility calls. # WHY: Pyproj is required for some calculations within recenter_and_redraw (e.g., BBox size for info panel). # HOW: Added check. if not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("Required libraries missing for resize redraw. Cannot redraw map.") self._clear_canvas_display() # Update map info panel to reflect disabled state if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after resize+clear (libs): {e}", exc_info=False) return # Exit if dependencies are missing if ( self._current_center_lat is not None and self._current_center_lon is not None and self._current_zoom is not None ): logger.debug("Recentering map view at current geo center after resize.") # The recenter_and_redraw method now takes width/height implicitly from canvas.winfo_*. self.recenter_and_redraw( self._current_center_lat, self._current_center_lon, self._current_zoom ) # If center/zoom are not available (shouldn't happen after init unless map clear happened), clear display. else: logger.warning("No valid geo center after resize. Clearing map display.") self.clear_map_display() # clear_map_display already updates info panel # MODIFIED: Created _fit_map_view_to_bbox helper method (was update_map_view_for_bbox). # WHY: This logic is used internally to calculate a view that fits a specific BBox. # Renamed to clarify its purpose and made private. # HOW: Renamed and updated docstring. def _fit_map_view_to_bbox( self, target_bbox_dict: Dict[str, float], ): """Calculate optimal center/zoom for a BBox and redraw the map view.""" # MODIFIED: Added comprehensive checks for necessary libraries and context. # WHY: Ensure function operates on valid inputs and dependencies are met. # HOW: Added checks. # MODIFIED: Check for Pyproj as well, needed by utilities called within this method. # WHY: Pyproj is needed for BBox size calculation. # HOW: Added check. if not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("Required libraries missing for BBox fitting. Cannot fit map view.") # Optionally show an error message on GUI? if self.app_controller and hasattr(self.app_controller, "show_error_message"): try: self.app_controller.show_error_message("Map Error", "Required map libraries missing for fitting.") except Exception as e: logger.error(f"Error showing map error message (fit libs): {e}", exc_info=False) return # Exit if dependencies are missing if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict): # _is_valid_bbox_dict logs specific reasons for failure. logger.warning(f"_fit_map_view_to_bbox called with invalid/no target BBox: {target_bbox_dict}. Ignoring request.") # Show input error message on GUI if self.app_controller and hasattr(self.app_controller, "show_error_message"): try: self.app_controller.show_error_message("Input Error", "Invalid bounding box coordinates.") except Exception as e: logger.error(f"Error showing input error message (fit view): {e}", exc_info=False) return # Exit if input BBox is invalid 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"] # Calculate the geometric center of the BBox. # Handle BBox crossing the antimeridian for longitude center. view_center_lat = (lat_min + lat_max) / 2.0 view_center_lon = (lon_min + lon_max) / 2.0 if lon_min > lon_max: # If BBox crosses antimeridian view_center_lon = (lon_min + (lon_max + 360.0)) / 2.0 if view_center_lon >= 180.0: view_center_lon -= 360.0 # Wrap back to -180 to 180 range logger.info(f"Calculating optimal view center/zoom to fit BBox: {target_bbox_dict}. Geometric Center: ({view_center_lat:.4f}, {view_center_lon:.4f})") # Calculate the optimal zoom level to fit the BBox within the current canvas dimensions. # This involves converting the geographic BBox to Mercator coordinates and scaling. # Get Mercator coordinates of the BBox corners # mercantile.xy returns (x, y) Mercator meters # Note: latitude must be clamped to MAX_LATITUDE_MERCATOR for mercantile.xy lat_min_clamped = max(-MAX_LATITUDE_MERCATOR, min(MAX_LATITUDE_MERCATOR, lat_min)) lat_max_clamped = max(-MAX_LATITUDE_MERCATOR, min(MAX_LATITUDE_MERCATOR, lat_max)) # If clamping reversed min/max, log warning (shouldn't happen if input BBox is valid lat range) if lat_min_clamped >= lat_max_clamped: logger.warning(f"Clamped latitude range invalid for Mercator conversion: S={lat_min_clamped:.4f}, N={lat_max_clamped:.4f}. Cannot fit map view accurately.") # Fallback: use default zoom? zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM logger.warning(f"Using default zoom {zoom_to_use} due to clamped latitude range issue.") else: # Get Mercator coordinates of the top-left and bottom-right corners of the BBox # (lon, lat) -> (x, y) Mercator meters ul_merc_x, ul_merc_y = mercantile.xy(lon_min, lat_max_clamped) # Top-left geo (lon_min, lat_max) -> Mercator (x, y) lr_merc_x, lr_merc_y = mercantile.xy(lon_max, lat_min_clamped) # Bottom-right geo (lon_max, lat_min) -> Mercator (x, y) # Calculate the Mercator width and height span of the BBox # Handle potential antimeridian crossing in the BBox merc_span_w = lr_merc_x - ul_merc_x if lon_min > lon_max: # If BBox crosses antimeridian (W > E) # The true span is the distance from W to 180, plus distance from -180 to E. # In mercator meters, this corresponds to (lon_max + 360 - lon_min) / 360 * (2*pi*R). # Using mercantile xy for the wrapped longitudes: merc_span_w = mercantile.xy(lon_max + 360.0, 0.0)[0] - mercantile.xy(lon_min, 0.0)[0] # Note: mercantile.xy(lon, lat) depends on lat for Y, but X is independent of lat. # So we can use 0.0 for lat when calculating X span based on longitudes. merc_span_h = ul_merc_y - lr_merc_y # Mercator Y increases Northwards (ul_y > lr_y) # Check for zero or near-zero Mercator spans if abs(merc_span_w) < 1e-9 or abs(merc_span_h) < 1e-9: logger.warning(f"BBox Mercator span zero or near-zero ({merc_span_w:.2e}x{merc_span_h:.2e} m). Cannot calculate optimal zoom.") # Fallback: use default zoom? zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM logger.warning(f"Using default zoom {zoom_to_use} due to zero Mercator span issue.") else: # Get current canvas dimensions current_canvas_width = self.canvas.winfo_width(); if current_canvas_width <= 1: current_canvas_width = self.canvas_width current_canvas_height = self.canvas.winfo_height(); if current_canvas_height <= 1: current_canvas_height = self.canvas_height if current_canvas_width <= 0 or current_canvas_height <= 0: logger.warning("Canvas dims zero/invalid. Cannot calc zoom to fit BBox. Using default."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: # Calculate the required Mercator meters per pixel for the canvas # required_merc_m_per_px_w = merc_span_w / current_canvas_width # required_merc_m_per_px_h = merc_span_h / current_canvas_height # The Mercator coordinate system has a scale where at zoom 0, # the entire world (360 degrees longitude) spans 2 * pi * R meters horizontally. # The number of pixels across the world at zoom Z is tile_pixel_size * 2^Z. # So, meters per pixel in Mercator space = (2 * pi * R) / (tile_pixel_size * 2^Z). This is constant horizontally. # Let's call this `merc_m_per_px_at_zoom`. # merc_m_per_px_at_zoom = (2 * math.pi * EARTH_RADIUS_METERS) / (self.tile_manager.tile_size * (2^Z)) # We want to find the zoom Z such that the BBox Mercator span fits within the canvas pixels # merc_span_w <= current_canvas_width * merc_m_per_px_at_zoom(Z) # merc_span_h <= current_canvas_height * merc_m_per_px_at_zoom(Z) # merc_span_w <= current_canvas_width * (2 * pi * R) / (tile_size * 2^Z) # merc_span_h <= current_canvas_height * (2 * pi * R) / (tile_size * 2^Z) # Rearrange to solve for 2^Z: # 2^Z <= current_canvas_width * (2 * pi * R) / (tile_size * merc_span_w) (from width constraint) # 2^Z <= current_canvas_height * (2 * pi * R) / (tile_size * merc_span_h) (from height constraint) # To fit the *entire* BBox, 2^Z must be <= MIN(width_term, height_term) # Let's use the required pixel span for the BBox. # The pixel span needed on the canvas for the BBox is (merc_span_w / merc_m_per_px_at_zoom, merc_span_h / merc_m_per_px_at_zoom). # We need merc_span_w / merc_m_per_px_at_zoom <= current_canvas_width AND merc_span_h / merc_m_per_px_at_zoom <= current_canvas_height # This means merc_m_per_px_at_zoom >= merc_span_w / current_canvas_width AND merc_m_per_px_at_zoom >= merc_span_h / current_canvas_height # So, merc_m_per_px_at_zoom >= MAX(merc_span_w / current_canvas_width, merc_span_h / current_canvas_height) # Let required_merc_m_per_px = MAX(merc_span_w / current_canvas_width, merc_span_h / current_canvas_height). # (2 * pi * R) / (tile_size * 2^Z) >= required_merc_m_per_px # (2 * pi * R) / (tile_size * required_merc_m_per_px) >= 2^Z # Z <= log2( (2 * pi * R) / (tile_size * required_merc_m_per_px) ) # Calculate required_merc_m_per_px, handling division by zero for canvas dimensions if current_canvas_width > 0 and current_canvas_height > 0: req_merc_m_per_px_w = abs(merc_span_w) / current_canvas_width if abs(merc_span_w) > 1e-9 else float('inf') # Use abs() for width span req_merc_m_per_px_h = abs(merc_span_h) / current_canvas_height if abs(merc_span_h) > 1e-9 else float('inf') # Use abs() for height span required_merc_m_per_px = max(req_merc_m_per_px_w, req_merc_m_per_px_h) if math.isfinite(required_merc_m_per_px) and required_merc_m_per_px > 1e-9: term_for_log = (2 * math.pi * EARTH_RADIUS_METERS) / (self.tile_manager.tile_size * required_merc_m_per_px) if term_for_log > 1e-9: precise_zoom = math.log2(term_for_log) # Round to nearest integer zoom level calculated_zoom_for_target_bbox = int(round(precise_zoom)) # Clamp calculated zoom to service limits max_zoom_limit = 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(calculated_zoom_for_target_bbox, max_zoom_limit)) logger.info(f"Calculated optimal zoom {zoom_to_use} to fit BBox (precise {precise_zoom:.2f}).") else: logger.warning(f"Term for log2 non-positive ({term_for_log:.2e}). Cannot calc optimal zoom. Using default ({map_constants.DEFAULT_INITIAL_ZOOM}).") zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: logger.warning(f"Calculated required_merc_m_per_px is non-finite or near-zero ({required_merc_m_per_px:.2e}). Cannot calc optimal zoom. Using default ({map_constants.DEFAULT_INITIAL_ZOOM}).") zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: logger.warning("Canvas dimensions zero or invalid for optimal zoom calculation. Using default.") zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM # Now redraw the map view with the calculated center and zoom level # The recenter_and_redraw method will fetch the appropriate tiles for this new view. # MODIFIED: Removed ensure_bbox_is_covered_dict parameter from the call to recenter_and_redraw. # WHY: Parameter no longer exists/used. # HOW: Removed argument. self.recenter_and_redraw(view_center_lat, view_center_lon, zoom_to_use) # MODIFIED: Created public method fit_view_to_bbox. # WHY: This method is intended to be called by the Controller/MainWindow # to explicitly change the map view to fit a specific BBox (triggered by "Center"). # HOW: Defined the method, calling the private _fit_map_view_to_bbox helper. def fit_view_to_bbox(self, target_bbox_dict: Dict[str, float]): """ Instructs the map to update its view (center and zoom) to optimally display the provided bounding box. This is triggered by the user (e.g., "Center" button). """ logger.info(f"Received request to fit map view to BBox: {target_bbox_dict}") # MODIFIED: Added check for essential dependencies before proceeding. # WHY: Prevent errors if PIL or Mercantile are not available. # HOW: Added check. # MODIFIED: Check for Pyproj as well, needed by _fit_map_view_to_bbox utilities. # WHY: Pyproj is needed for calculating BBox size. # HOW: Added check. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING is checked here instead of PIL_IMAGE_LIB_AVAILABLE. # WHY: Use the variable defined by the try/except block above for the PIL check. # HOW: Replaced variable name. if not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("Cannot fit view to BBox: Required libraries (Pillow, Mercantile, or Pyproj) are missing.") # Optionally show an error message on GUI if self.app_controller and hasattr(self.app_controller, "show_error_message"): try: self.app_controller.show_error_message("Map Error", "Required map libraries missing for fitting.") except Exception as e: logger.error(f"Error showing map error message (fit libs): {e}", exc_info=False) return # Validate the input BBox before calling the fitting logic if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict): # _is_valid_bbox_dict logs specific reasons for failure. logger.warning(f"fit_view_to_bbox called with invalid/none target BBox: {target_bbox_dict}. Ignoring request.") # Show input error message on GUI if self.app_controller and hasattr(self.app_controller, "show_error_message"): try: self.app_controller.show_error_message("Input Error", "Invalid bounding box coordinates.") except Exception as e: logger.error(f"Error showing input error message (fit view): {e}", exc_info=False) return # Call the internal helper to calculate and apply the new view self._fit_map_view_to_bbox(target_bbox_dict) # MODIFIED: Simplified recenter_and_redraw method signature and logic. # WHY: This method should now simply redraw the map centered at the given point with the given zoom level. # The logic for *calculating* the center/zoom (either to fit a BBox or from pan/zoom) is handled by # the calling methods (_fit_map_view_to_bbox or the pan/zoom handlers). # The ensure_bbox_is_covered_dict parameter is removed as it's no longer used for fetching bounds. # HOW: Removed ensure_bbox_is_covered_dict parameter and related logic. Adjusted tile range calculation. def recenter_and_redraw( self, center_lat: float, center_lon: float, zoom_level: int, ): """ Update map view center/zoom, fetch tiles for the current canvas area, and redraw canvas with the base map and overlays. """ logger.info(f"Recenter/Redraw map. Center: ({center_lat:.4f}, {center_lon:.4f}), Zoom: {zoom_level}.") # MODIFIED: Check for necessary dependencies early. # WHY: Avoid errors later in the method. # HOW: Added check. # MODIFIED: Check for Pyproj as well, needed by recenter_and_redraw utility calls. # WHY: Pyproj is required for some calculations within recenter_and_redraw (e.g., BBox size for info panel). # HOW: Added check. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING is checked here instead of PIL_IMAGE_LIB_AVAILABLE. # WHY: Use the variable defined by the try/except block above for the PIL check. # HOW: Replaced variable name. if not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("Required libraries missing for recenter/redraw (Pillow, Mercantile, Pyproj). Cannot redraw.") self._clear_canvas_display() # Ensure canvas is clear if map is disabled # Update map info panel to reflect disabled state if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw fail (libs): {e}", exc_info=False) return # Exit if dependencies are missing # Clamp center lat/lon to valid ranges (especially important for Mercator projection) # MODIFIED: Use the constant MAX_LATITUDE_MERCATOR. # WHY: Centralize constants. # HOW: Referenced the constant. clamped_center_lat = max(-MAX_LATITUDE_MERCATOR, min(MAX_LATITUDE_MERCATOR, center_lat)) clamped_center_lon = (center_lon + 180.0) % 360.0 - 180.0 # Wrap longitude to -180 to 180 # Validate and clamp zoom level to supported range max_zoom_limit = self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK min_zoom_limit = map_constants.MIN_ZOOM_LEVEL # Ensure requested zoom is within practical and service limits clamped_zoom = max(min_zoom_limit, min(zoom_level, max_zoom_limit)) # Update instance state with the clamped view parameters # This represents the center and zoom the user *intends* to view. self._current_center_lat = clamped_center_lat self._current_center_lon = clamped_center_lon self._current_zoom = clamped_zoom logger.debug(f"Using clamped center ({self._current_center_lat:.4f}, {self._current_center_lon:.4f}) and clamped zoom {self._current_zoom}.") # Get current canvas dimensions (should be valid by this point due to init/resize handling) current_canvas_width = self.canvas.winfo_width(); # MODIFIED: Use self.canvas_width/height as fallback only if winfo_* returns <= 1. # WHY: Ensure valid dimensions are used. # HOW: Added fallback logic. if current_canvas_width <= 1: current_canvas_width = self.canvas_width # MODIFIED: Corrected the line to get current_canvas_height. # WHY: It had a typo and assignment. # HOW: Corrected the assignment. current_canvas_height = self.canvas.winfo_height(); if current_canvas_height <= 1: current_canvas_height = self.canvas_height # Corrected line # Final check on canvas dimensions before proceeding if current_canvas_width <= 0 or current_canvas_height <= 0: logger.error(f"Canvas dims invalid ({current_canvas_width}x{current_canvas_height}). Cannot redraw.") self._clear_canvas_display() # Ensure canvas is clear if redraw fails # Update map info panel to reflect invalid state if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw fail (dims): {e}", exc_info=False) return # Exit if dimensions are invalid # Calculate the geographic bounding box that *fills* the current canvas view # centered at the current center, at the current zoom, with current canvas dimensions. # This BBox defines the geographic area the *stitched image* should cover. # MODIFIED: Use the current clamped center, zoom, and canvas dimensions. # WHY: Calculate the geo area needed for the *current view*. # HOW: Passed appropriate parameters. # MODIFIED: Check if calculate_geographic_bbox_from_pixel_size_and_zoom is available from map_utils. # WHY: Avoid AttributeError. # HOW: Added check. bbox_from_pixel_func = calculate_geographic_bbox_from_pixel_size_and_zoom if bbox_from_pixel_func is not None: canvas_fill_geo_bbox = bbox_from_pixel_func( self._current_center_lat, self._current_center_lon, current_canvas_width, current_canvas_height, self._current_zoom, self.tile_manager.tile_size, ) else: logger.error("map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom function is None. Cannot calculate canvas fill BBox.") canvas_fill_geo_bbox = None # Ensure it's None # Store the calculated geo bounds of the area the stitched image *will* cover. # This is needed for the geo-to-pixel conversions when drawing overlays. # If calculation fails, _current_map_geo_bounds remains None or previous value. if canvas_fill_geo_bbox: self._current_map_geo_bounds = canvas_fill_geo_bbox logger.debug(f"Calculated canvas fill geo bounds for fetch/draw: {self._current_map_geo_bounds}") else: logger.error("Failed to calculate canvas fill geo BBox. Cannot determine fetch bounds or draw overlays correctly.") self._current_map_geo_bounds = None # Ensure bounds are None if calculation failed. self._clear_canvas_display() # Ensure canvas is clear if calculation fails # Update map info panel if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw fail (bbox calc): {e}", exc_info=False) return # Exit if calculation failed. # Get the tile ranges needed to cover the calculated canvas fill geo BBox. # These are the tiles that will be fetched and stitched. # MODIFIED: Use canvas_fill_geo_bbox to get tile ranges. # WHY: We now fetch tiles only for the visible canvas area. # HOW: Passed canvas_fill_geo_bbox to get_tile_ranges_for_bbox. # MODIFIED: Check if get_tile_ranges_for_bbox is available from map_utils. # WHY: Avoid AttributeError. # HOW: Added check. tile_ranges_func = get_tile_ranges_for_bbox if tile_ranges_func is not None: tile_xy_ranges = tile_ranges_func(self._current_map_geo_bounds, self._current_zoom) else: logger.error("map_utils.get_tile_ranges_for_bbox is None. Cannot get tile ranges.") tile_xy_ranges = None # Ensure tile_xy_ranges is None if the function is missing if not tile_xy_ranges: logger.error(f"Failed to get tile ranges for canvas BBox {self._current_map_geo_bounds} at zoom {self._current_zoom}. Cannot draw.") self._clear_canvas_display() # Draw a placeholder indicating error on the canvas itself # MODIFIED: Check PIL dependencies before attempting to draw placeholder. # WHY: Avoid errors if drawing libs are missing. # HOW: Added checks. # MODIFIED: Check if map_drawing and _draw_text_on_placeholder are available. # WHY: Avoid AttributeError. # HOW: Added checks. draw_placeholder_text_func = getattr(map_drawing, '_draw_text_on_placeholder', None) if PIL_LIB_AVAILABLE_DRAWING and Image is not None and ImageDraw is not None and ImageFont is not None and map_drawing is not None and draw_placeholder_text_func is not None: try: # Create a placeholder image matching canvas size placeholder_img = Image.new("RGB", (current_canvas_width, current_canvas_height), map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB) if ImageDraw: draw = ImageDraw.Draw(placeholder_img) # Use the helper method for drawing text on placeholder draw_placeholder_text_func(draw, placeholder_img.size, "Map Error\nCannot get tiles.") # Draw the placeholder image on the canvas if self.canvas.winfo_exists(): self._clear_canvas_display(); # Ensure existing items are cleared self._map_photo_image = ImageTk.PhotoImage(placeholder_img) self._canvas_image_id = self.canvas.create_image(0, 0, anchor=tk.NW, image=self._map_photo_image) logger.debug("Drew tile range error placeholder.") else: logger.warning("Canvas gone, cannot draw tile range error placeholder.") else: logger.error("ImageDraw is None, cannot draw text on tile range error placeholder.") except Exception as e_placeholder: logger.error(f"Failed to draw tile range error placeholder: {e_placeholder}", exc_info=True) # Update map info panel to reflect error if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw fail (tiles): {e}", exc_info=False) return # Exit if tile ranges could not be determined logger.debug(f"Tile ranges for current view ({current_canvas_width}x{current_canvas_height}) at zoom {self._current_zoom}: X={tile_xy_ranges[0]}, Y={tile_xy_ranges[1]}") # Stitch the map image from the fetched tiles covering the tile ranges # The stitched image should ideally match the canvas pixel dimensions. # MapTileManager.stitch_map_image handles fetching (cache/download) and joining. # It returns a PIL Image or None on critical failure. # MODIFIED: Check if tile_manager and its stitch_map_image method are available. # WHY: Avoid AttributeError. # HOW: Added checks. stitch_func = getattr(self.tile_manager, 'stitch_map_image', None) if self.tile_manager is not None and stitch_func is not None: stitched_map_pil = stitch_func( self._current_zoom, tile_xy_ranges[0], # X tile range tile_xy_ranges[1] # Y tile range ) else: logger.error("MapTileManager or stitch_map_image method is None. Cannot stitch map.") stitched_map_pil = None # Ensure it's None # Check if stitching was successful if not stitched_map_pil: logger.error("Failed to stitch map image.") self._clear_canvas_display() # Ensure canvas is clear if stitching fails # Update map info panel if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw fail (stitch): {e}", exc_info=False) return # The stitched image *should* have pixel dimensions corresponding to the number of tiles # multiplied by the tile size. Its geographic bounds are precisely determined by the # min/max tiles in the ranges. Let's update _current_map_geo_bounds based on the *actual* # tiles stitched, which is more accurate than the canvas_fill_geo_bbox. # MapTileManager._get_bounds_for_tile_range calculates these precise bounds. # MODIFIED: Check if tile_manager and its _get_bounds_for_tile_range method are available. # WHY: Avoid AttributeError. # HOW: Added checks. get_bounds_func = getattr(self.tile_manager, '_get_bounds_for_tile_range', None) if self.tile_manager is not None and get_bounds_func is not None: actual_stitched_geo_bounds = get_bounds_func(self._current_zoom, tile_xy_ranges) if actual_stitched_geo_bounds: self._current_map_geo_bounds = actual_stitched_geo_bounds logger.debug(f"Actual stitched map geo bounds calculated from tiles: {self._current_map_geo_bounds}") else: # Fallback: If calculating bounds from tiles fails, use the calculated canvas fill bounds. # This is less accurate but better than nothing for overlay positioning. logger.warning("Failed to get actual stitched bounds from tiles. Using calculated canvas fill bounds as fallback.") # _current_map_geo_bounds was already set to canvas_fill_geo_bbox earlier, so it retains that value. if self._current_map_geo_bounds is None: # If even canvas_fill_geo_bbox failed, something is very wrong logger.critical("Neither stitched nor canvas fill bounds available after redraw attempt.") self._clear_canvas_display() # Ensure canvas is clear if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw fail (bounds critical): {e}", exc_info=False) return # Critical failure else: logger.error("MapTileManager or _get_bounds_for_tile_range method is None. Cannot get actual stitched bounds.") # Use the canvas fill bbox as a fallback if the method is missing. if self._current_map_geo_bounds is None: logger.critical("Canvas fill bounds also None. Cannot determine geo bounds for redraw.") self._clear_canvas_display() if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw fail (bounds critical 2): {e}", exc_info=False) return # Critical failure # Store the successfully stitched PIL image self._map_pil_image = stitched_map_pil # Redraw the canvas content with the new base map image and overlays # This method will draw the base map image and then call drawing utilities for overlays. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING before redrawing. # WHY: _redraw_canvas_content relies heavily on PIL. # HOW: Added check. # MODIFIED: Ensure _map_pil_image exists and _current_map_geo_bounds are set before calling _redraw_canvas_content. # WHY: _redraw_canvas_content requires these for drawing. # HOW: Added check. # MODIFIED: Ensure map drawing dependencies are available before calling _redraw_canvas_content. # WHY: _redraw_canvas_content relies on map_drawing. # HOW: Added check for Image, ImageDraw, ImageFont. if PIL_LIB_AVAILABLE_DRAWING and Image is not None and ImageDraw is not None and ImageFont is not None and self._map_pil_image is not None and self._current_map_geo_bounds is not None: self._redraw_canvas_content() # Update map info panel after successful redraw if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after redraw success: {e}", exc_info=False) elif self.app_controller and hasattr(self.app_controller, "update_general_map_info"): # Update map info panel even if redraw failed but map_info update is still desired # (e.g., to show center/zoom even if no image could be drawn) try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info during redraw fail path: {e}", exc_info=False) else: # If app_controller/update_general_map_info is not available, just log logger.debug("App controller or update_general_map_info not available after redraw attempt.") def _redraw_canvas_content(self): """ Draw base map image item and all overlay items (BBox outline, flights, DEM boundaries). Runs on the GUI thread. """ logger.debug("MapCanvasManager: _redraw_canvas_content called.") # MODIFIED: Check for necessary PIL dependencies before drawing. # WHY: Avoid errors if drawing libs are missing. # HOW: Added check. # MODIFIED: Ensure Image and ImageDraw are available specifically. # WHY: These are core for creating/drawing on images. # HOW: Added check. # MODIFIED: Ensure map_drawing module is available. # WHY: This module contains the drawing helper functions. # HOW: Added check. if not PIL_LIB_AVAILABLE_DRAWING or Image is None or ImageDraw is None or map_drawing is None: logger.warning("_redraw_canvas_content: Pillow (Image/ImageDraw) or map_drawing module not available. Cannot draw.") self._clear_canvas_display() # Ensure canvas is clear if drawing is disabled return # Clear existing drawings (base image and all overlays) before redrawing everything self._clear_canvas_display() # Check if we have a base map image and geographic bounds to draw on # These are required to draw anything other than a blank canvas. if self._map_pil_image is None or self._current_map_geo_bounds is None: logger.warning("No base map image or geographic bounds to draw overlays on. Canvas cleared.") # No further drawing is possible without a base map image and its bounds. return # Exit the method # Create a copy of the base map image to draw overlays on. # Drawing on a copy avoids modifying the original base tile image data. # MODIFIED: Check if Image class is available before copying. # WHY: Avoid error if Image is None despite PIL_LIB_AVAILABLE_DRAWING being true (edge case). # HOW: Added check. if Image: image_to_draw_on = self._map_pil_image.copy() else: logger.error("Pillow Image class missing. Cannot copy image for overlays.") # Return here, as drawing overlays requires a valid image copy. return # Cannot draw overlays if image cannot be copied # --- Draw Overlays --- # Draw user target BBox outline (if _target_bbox_input is set and valid) # This BBox is the one from the GUI input fields or the monitoring area. # It's drawn as a boundary on the *current* map view. if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input): # logger.debug(f"Drawing target BBox outline: {self._target_bbox_input}") # Too verbose # Pass the image to draw on, the BBox to draw, and the context (map bounds, pixel shape). # map_drawing.draw_area_bounding_box handles geo-to-pixel conversion internally. # It uses the map_geo_bounds and map_pixel_shape of the image_to_draw_on. # MODIFIED: Check if map_drawing and draw_area_bounding_box are available. # WHY: Avoid AttributeError. # HOW: Added checks. if map_drawing is not None and hasattr(map_drawing, 'draw_area_bounding_box'): try: image_to_draw_on = map_drawing.draw_area_bounding_box( image_to_draw_on, # Draw the BBox stored in _target_bbox_input ( self._target_bbox_input["lon_min"], self._target_bbox_input["lat_min"], self._target_bbox_input["lon_max"], self._target_bbox_input["lat_max"] ), self._current_map_geo_bounds, # Pass the geo bounds of the stitched image self._map_pil_image.size # Pass the pixel shape of the stitched image ) except Exception as e: logger.error(f"Error drawing target BBox outline: {e}", exc_info=False) else: logger.warning("map_drawing module or draw_area_bounding_box function not available. Cannot draw BBox outline.") # else: logger.debug("No target BBox outline to draw.") # Too verbose # Draw flights (if any are loaded) flights_drawn_count = 0 if self._current_flights_to_display: # Check if ImageDraw is available before creating the drawing context. if ImageDraw is not None: draw = ImageDraw.Draw(image_to_draw_on) # Load font for flight labels - scale based on current map zoom relative to base zoom # Use constants for base font size and zoom, providing fallbacks. base_font_size = getattr(map_constants, 'DEM_TILE_LABEL_BASE_FONT_SIZE', 12) base_zoom = getattr(map_constants, 'DEM_TILE_LABEL_BASE_ZOOM', 10) current_zoom_for_font = self._current_zoom if self._current_zoom is not None else base_zoom # MODIFIED: Check if map_drawing and _load_label_font are available. # WHY: Avoid AttributeError. # HOW: Added checks. load_font_func = getattr(map_drawing, '_load_label_font', None) if map_drawing is not None and load_font_func is not None: label_font = load_font_func( base_font_size + (current_zoom_for_font - base_zoom) # Adjust size based on zoom difference ) else: logger.warning("map_drawing module or _load_label_font function not available. Cannot load font for labels.") label_font = None # Ensure label_font is None if loading fails # Define a margin around the image where we still attempt to draw points/labels. # This prevents markers right at the edge from disappearing. margin = 20 # pixels margin around image bounds # Check if the necessary utility function for geo-to-pixel is available # The _draw_single_flight expects pixel coordinates, so we need to convert here. # map_utils.geo_to_pixel handles its own mercantile check. geo_to_pixel_func = geo_to_pixel # Use the imported function # MODIFIED: Check if map_drawing and _draw_single_flight are available. # WHY: Avoid AttributeError. # HOW: Added checks. draw_single_flight_func = getattr(map_drawing, '_draw_single_flight', None) # MODIFIED: Check if CanonicalFlightState is available before iterating over flights. # WHY: Prevent errors if the model class isn't imported due to errors. # HOW: Added check for CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING. if CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING and geo_to_pixel_func is not None and draw_single_flight_func is not None: for flight in self._current_flights_to_display: # Ensure the object is a valid CanonicalFlightState and has coordinates if isinstance(flight, CanonicalFlightState) and flight.latitude is not None and flight.longitude is not None: # Convert flight geo coordinates to pixel coordinates *on the stitched image*. # This conversion uses the geographic bounds and pixel shape of the *currently displayed* map image. # MODIFIED: Call the centralized map_utils.geo_to_pixel function. # WHY: Use the corrected and robust conversion utility. # HOW: Called the function with appropriate parameters. pixel_coords = geo_to_pixel_func( flight.latitude, flight.longitude, self._current_map_geo_bounds, # Pass the geo bounds of the stitched image self._map_pil_image.size # Pass the pixel shape of the stitched image ) # Only draw the flight if its calculated pixel position is within the image bounds plus a margin if pixel_coords: px, py = pixel_coords img_w, img_h = image_to_draw_on.size # Get dimensions from the image being drawn on # Check if the pixel is within the drawing margin. if ( (px >= -margin and px < img_w + margin) and (py >= -margin and py < img_h + margin) ): try: # Call the helper function to draw a single flight marker and label draw_single_flight_func(draw=draw, pixel_coords=(px, py), flight_state=flight, label_font=label_font) flights_drawn_count += 1 except Exception as e: logger.error(f"Error drawing flight {flight.icao24} at ({px},{py}): {e}", exc_info=False) # else: logger.debug(f"Flight {flight.icao24} at ({px},{py}) outside drawing margin. Skipping draw.") # Too verbose # else: logger.debug(f"Geo-to-pixel failed for flight {flight.icao24}. Skipping draw.") # Too verbose logger.debug(f"Attempted to draw {len(self._current_flights_to_display)} flights, {flights_drawn_count} drawn.") else: if not CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING: logger.error("CanonicalFlightState model not available. Cannot draw flights.") if geo_to_pixel_func is None: logger.error("map_utils.geo_to_pixel is None. Cannot convert geo coords for flight drawing.") if draw_single_flight_func is None: logger.error("map_drawing._draw_single_flight function is None. Cannot draw single flight.") else: logger.warning("Pillow ImageDraw not available, skipping drawing flights.") # Draw other potential overlays here in the future (e.g., DEM boundaries) # Example (needs relevant DEM tile info list to be passed/available): # if self._show_dem_boundaries and hasattr(map_drawing, 'draw_dem_tile_boundaries_with_labels'): # try: # # You would need a way to get the list of DEM tiles relevant to the current view here # # This would likely involve calculating which DEM tiles intersect the _current_map_geo_bounds # relevant_dem_tiles_info = self._get_relevant_dem_tiles_info(self._current_map_geo_bounds) # if relevant_dem_tiles_info and current_zoom_for_font is not None: # Need zoom for label scaling # # MODIFIED: Check if map_drawing.draw_dem_tile_boundaries_with_labels is available. # # WHY: Avoid AttributeError. # # HOW: Added check. # draw_dem_func = getattr(map_drawing, 'draw_dem_tile_boundaries_with_labels', None) # if map_drawing is not None and draw_dem_func is not None: # image_to_draw_on = draw_dem_func( # image_to_draw_on, # relevant_dem_tiles_info, # self._current_map_geo_bounds, # Geo bounds of the stitched image # current_zoom_for_font, # Current zoom level # self._map_pil_image.size # Pixel shape of the stitched image # ) # else: logger.warning("map_drawing module or draw_dem_tile_boundaries_with_labels function not available. Cannot draw DEM overlays.") # # except Exception as e: # logger.error(f"Error drawing DEM boundaries: {e}", exc_info=False) # --- Display the composite image on the Canvas --- # Convert the composite PIL image (with overlays) to a Tkinter PhotoImage try: # MODIFIED: Check if ImageTk is available before creating PhotoImage. # WHY: Avoid error if ImageTk is None. # HOW: Added check. if ImageTk: self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on) logger.debug("Created PhotoImage from composite PIL image.") else: logger.error("Pillow ImageTk missing. Cannot create PhotoImage for canvas display."); self._clear_canvas_display(); return # Cannot display if no PhotoImage except Exception as e: logger.error(f"Failed to create PhotoImage for canvas display: {e}", exc_info=True) self._clear_canvas_display() # Ensure canvas is clear if PhotoImage creation fails return # Display the PhotoImage on the Tkinter canvas # MODIFIED: Ensure canvas widget exists before creating canvas image item. # WHY: Avoids TclError. # HOW: Added winfo_exists() check. if hasattr(self, 'canvas') and self.canvas is not None and self.canvas.winfo_exists(): try: # Clear previous canvas content (base image and all overlays) self._clear_canvas_display() # This is done at the start of _redraw_canvas_content, but defensive check. # Create the base image item on the canvas self._canvas_image_id = self.canvas.create_image(0, 0, anchor=tk.NW, image=self._map_photo_image) logger.debug("Canvas redrawn with new base image item and overlays.") # Note: Other drawing functions (like drawing individual flight markers) should *not* # create separate canvas items if drawing on the PIL image. # All drawing of overlays happens on the PIL image before it's converted to PhotoImage. except tk.TclError as e: # This can happen if the canvas is destroyed between the winfo_exists() check and create_image() logger.warning(f"TclError drawing base canvas image item: {e}. GUI likely gone.") self._canvas_image_id = None # Ensure ID is None on error except Exception as e: logger.error(f"Unexpected error drawing base canvas image item: {e}", exc_info=True); self._canvas_image_id = None # Ensure ID is None on error def _clear_canvas_display(self): """Clears all canvas items and releases the PhotoImage reference.""" # MODIFIED: Added deletion of all canvas items. # WHY: To ensure all drawn elements (base map and overlays) are removed. # HOW: Used `self.canvas.delete("all")`. # MODIFIED: Check if canvas object exists before deleting items. # WHY: Prevent AttributeError/TclError. # HOW: Added check. if hasattr(self, 'canvas') and self.canvas is not None and self.canvas.winfo_exists(): try: self.canvas.delete("all") # Delete ALL items (base image, overlays, placeholder text) logger.debug("Cleared all items from canvas.") except tk.TclError: logger.warning("TclError deleting all canvas items. Canvas gone.") except Exception as e: logger.error(f"Error deleting all canvas items: {e}", exc_info=False) # Clear the PhotoImage reference to allow garbage collection self._map_photo_image = None self._canvas_image_id = None # Reset the base image item ID def clear_map_display(self): """Clears all map content (canvas, image, flights, bounds, center, target BBox).""" logger.info("MapCanvasManager: Clearing all map content.") self._clear_canvas_display() # Clear canvas and PhotoImage # Reset internal state variables related to the displayed map and data self._map_pil_image = None # Clear the underlying PIL image self._current_flights_to_display = [] # Clear flights self._current_map_geo_bounds = None # Clear geo bounds of the displayed map # MODIFIED: Clear the target BBox input outline as well. # WHY: The BBox outline should not persist if the data/view is cleared. # HOW: Set _target_bbox_input to None. self._target_bbox_input = None # Reset view parameters to a default state (World view) self._current_center_lat = 0.0 # Default center latitude self._current_center_lon = 0.0 # Default center longitude self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM # Default initial zoom level logger.debug(f"Reset map view state to default: Center (0,0), Zoom {map_constants.DEFAULT_INITIAL_ZOOM}.") # Update map info panel to reflect cleared state # MODIFIED: Check if app_controller and its update method exist. # WHY: Avoid AttributeError. # HOW: Added check. if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: self.app_controller.update_general_map_info() except Exception as e: logger.error(f"Error updating map info after full clear: {e}", exc_info=False) def _on_mouse_wheel_windows_macos(self, event: tk.Event): """Handle mouse wheel zoom (Windows/macOS).""" # MODIFIED: Check if canvas exists before handling event. # WHY: Prevent errors if canvas is destroyed. # HOW: Added check. if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): return zoom_direction = 1 if event.delta > 0 else -1 # +1 for zoom in, -1 for zoom out self._handle_zoom(zoom_direction, event.x, event.y) def _on_mouse_wheel_linux(self, event: tk.Event): """Handle mouse wheel zoom (Linux).""" # MODIFIED: Check if canvas exists before handling event. # WHY: Prevent errors if canvas is destroyed. # HOW: Added check. if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): return zoom_direction = 0 if event.num == 4: zoom_direction = 1 # Scroll up (usually zoom in) elif event.num == 5: zoom_direction = -1 # Scroll down (usually zoom out) if zoom_direction != 0: self._handle_zoom(zoom_direction, event.x, event.y) # MODIFIED: Implement debouncing for the zoom handler similar to resize. # WHY: Avoid continuous map redraws while scrolling for zoom. # HOW: Use canvas.after_cancel and canvas.after to schedule the actual zoom logic. def _handle_zoom(self, zoom_direction: int, canvas_x: int, canvas_y: int): """Internal zoom handler with debouncing.""" # MODIFIED: Added checks for necessary dependencies before proceeding. # WHY: Avoid errors if libs or map context is missing. # HOW: Added checks. # MODIFIED: Check for Pyproj as well, needed by pixel_to_geo for some Mercator calculations (though mostly mercantile). # WHY: Safety check. # HOW: Added check. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING is checked here instead of PIL_IMAGE_LIB_AVAILABLE. # WHY: Use the variable defined by the try/except block at the start of the module. # HOW: Replaced variable name. if not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.warning("Required libraries missing during zoom. Cannot zoom.") # Optionally update status? if self.app_controller and hasattr(self.app_controller, "update_semaphore_and_status"): try: self.app_controller.update_semaphore_and_status("WARNING", "Cannot zoom (map libraries missing).") except Exception as e: logger.error(f"Error updating status for zoom fail (libs): {e}", exc_info=False) return # Exit if prerequisites are not met # Check if we have a valid current map view state (base image and bounds) if self._current_zoom is None or self._current_map_geo_bounds is None or self._map_pil_image is None: logger.warning("Map context missing during zoom. Cannot zoom.") # Optionally update status? if self.app_controller and hasattr(self.app_controller, "update_semaphore_and_status"): try: self.app_controller.update_semaphore_and_status("WARNING", "Cannot zoom (map not ready).") except Exception as e: logger.error(f"Error updating status for zoom fail (context): {e}", exc_info=False) return # Exit if map state is incomplete # Store the parameters needed for the zoom operation # MODIFIED: Use specific pending zoom variables. # WHY: Clarity, separate from potential resize parameters if same _debounce_job_id is reused. # HOW: Added new attributes. self._pending_zoom_direction = zoom_direction self._pending_zoom_canvas_x = canvas_x self._pending_zoom_canvas_y = canvas_y # Cancel any existing debounced job (resize or zoom) # MODIFIED: Use _debounce_job_id for consistency. # WHY: Cancel the scheduled job regardless of its origin. # HOW: Used _debounce_job_id. if self._debounce_job_id: try: self.canvas.after_cancel(self._debounce_job_id) # logger.debug("Cancelled previous debounced redraw job.") # Too verbose except tk.TclError: # This happens if the job already fired or the canvas is gone # logger.debug("TclError cancelling debounced redraw job.") # Too verbose pass # Ignore TclError except Exception as e: logger.error(f"Error cancelling debounced redraw job: {e}", exc_info=False) finally: self._debounce_job_id = None # Ensure ID is cleared # Schedule the actual zoom operation after a delay # MODIFIED: Use _debounce_job_id for consistency. # WHY: Schedule the new job. # HOW: Assigned to _debounce_job_id. self._debounce_job_id = self.canvas.after( RESIZE_DEBOUNCE_DELAY_MS, # Use the same debounce delay for both resize and zoom self._perform_zoom_redraw # Call the function that does the zoom logic # No arguments needed here, _perform_zoom_redraw will use the pending variables ) # logger.debug(f"Scheduled zoom redraw job (ID: {self._debounce_job_id}) for {RESIZE_DEBOUNCE_DELAY_MS}ms.") # Too verbose # MODIFIED: Created a new method to contain the actual zoom redrawing logic. # WHY: This method is called by canvas.after() after the debounce delay. # HOW: Defined the new method, using the pending variables stored in _handle_zoom. def _perform_zoom_redraw(self): """Performs the map redraw after a debounced zoom event.""" # Clear the job ID since the job is now executing. # MODIFIED: Use _debounce_job_id for consistency. # WHY: Clear the ID used for scheduling. # HOW: Assigned None to _debounce_job_id. self._debounce_job_id = None # Ensure canvas is still valid if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): # logger.debug("Canvas is gone, cannot perform zoom redraw.") # Too verbose return # Exit if canvas is destroyed # Retrieve the pending zoom parameters # MODIFIED: Use the specific pending zoom variables. # WHY: Use the correct stored values. # HOW: Referenced the new attribute names. zoom_direction = getattr(self, '_pending_zoom_direction', None) # Get stored value, handle None canvas_x = getattr(self, '_pending_zoom_canvas_x', None) # Get stored value, handle None canvas_y = getattr(self, '_pending_zoom_canvas_y', None) # Get stored value, handle None # Check for map context and libs availability before proceeding with the zoom logic # MODIFIED: Added comprehensive checks for necessary dependencies. # WHY: Avoid errors later in the method. # HOW: Added checks. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING is checked here instead of PIL_IMAGE_LIB_AVAILABLE. # WHY: Use the variable defined by the try/except block at the start of the module. # HOW: Replaced variable name. # MODIFIED: Check for Pyproj as well, needed by pixel_to_geo for some Mercator calculations (though mostly mercantile). # WHY: Safety check. # HOW: Added check. if self._current_zoom is None or self._current_map_geo_bounds is None or self._map_pil_image is None or not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.warning("Map context or required libraries missing during debounced zoom. Cannot zoom.") # No need to clear display or update status here, state is already reflecting the issue. return # Exit if prerequisites are not met # Calculate the target zoom level (clamped) max_zoom_limit = self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK min_zoom_limit = map_constants.MIN_ZOOM_LEVEL current_zoom = self._current_zoom # Use the currently displayed zoom target_zoom = current_zoom + (zoom_direction if zoom_direction is not None else 0) # Apply zoom direction new_zoom = max(min_zoom_limit, min(target_zoom, max_zoom_limit)) # Clamp to valid range # Only redraw if the zoom level has actually changed if new_zoom != current_zoom: logger.info(f"Zoom changed from {current_zoom} to {new_zoom} (debounced).") # Calculate the geographic coordinates corresponding to the mouse position on the *current* map image. # We want the map to recenter such that this geographic point remains under the mouse cursor # at the new zoom level. map_pixel_shape = self._map_pil_image.size # Dimensions of the currently displayed image # MODIFIED: Call the centralized map_utils.pixel_to_geo function. # WHY: Use the corrected and robust conversion utility. # HOW: Called the function with appropriate parameters. # MODIFIED: Check if map_utils.pixel_to_geo is available. # WHY: Avoid AttributeError. # HOW: Added check. pixel_to_geo_func = pixel_to_geo if pixel_to_geo_func is not None: geo_lon_at_mouse, geo_lat_at_mouse = pixel_to_geo_func( canvas_x if canvas_x is not None else self.canvas_width // 2, # Use mouse x, fallback to canvas center x canvas_y if canvas_y is not None else self.canvas_height // 2, # Use mouse y, fallback to canvas center y self._current_map_geo_bounds, # Pass the geo bounds of the stitched image map_pixel_shape # Pass the pixel shape of the stitched image ) else: logger.error("map_utils.pixel_to_geo is None. Cannot convert pixel coords for zoom center.") geo_lon_at_mouse, geo_lat_at_mouse = None, None # Ensure they are None if conversion fails # If mouse was over a valid geographic point, set the new center to that point. # Otherwise (e.g., click outside map bounds or conversion failed), keep the current geographic center. target_center_lat = geo_lat_at_mouse if geo_lat_at_mouse is not None else self._current_center_lat target_center_lon = geo_lon_at_mouse if geo_lon_at_mouse is not None else self._current_center_lon # Ensure the target center is valid before redrawing if target_center_lat is not None and target_center_lon is not None: # Recenter and redraw the map with the new center and zoom # MODIFIED: Removed ensure_bbox_is_covered_dict parameter from recenter_and_redraw call. # WHY: Parameter no longer exists/used. # HOW: Removed argument. self.recenter_and_redraw(target_center_lat, target_center_lon, new_zoom) else: logger.warning("Could not determine valid zoom center during debounced zoom.") # Even if center couldn't be determined from mouse, maybe just apply the zoom at the old center? # Let's try applying zoom at the current center as a fallback. if self._current_center_lat is not None and self._current_center_lon is not None: logger.debug("Using current center as fallback for zoom redraw.") # MODIFIED: Removed ensure_bbox_is_covered_dict parameter. # WHY: Parameter no longer exists/used. # HOW: Removed argument. self.recenter_and_redraw(self._current_center_lat, self._current_center_lon, new_zoom) else: logger.error("Current center is also None. Cannot redraw map after zoom.") self.clear_map_display() # Clear if map state is totally invalid # else: logger.debug("Zoom unchanged (debounced)."); # Too verbose def _on_mouse_button_press(self, event: tk.Event): """Handle mouse button press for pan/drag.""" # MODIFIED: Check if canvas exists before proceeding. # WHY: Prevent errors if canvas is destroyed. # HOW: Added check. if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): return # Store the canvas coordinates where the drag started self._drag_start_x_canvas, self._drag_start_y_canvas = event.x, event.y # Store the geographic center of the map at the START of the drag. # This is the reference point for calculating the pan delta on button release. if self._current_center_lat is not None and self._current_center_lon is not None: self._drag_start_center_lon, self._drag_start_center_lat = self._current_center_lon, self._current_center_lat else: logger.warning("Mouse press but current geo center unknown. Cannot pan."); self._drag_start_center_lon = None self._drag_start_center_lat = None # Ensure they are None # Reset drag state flag and cursor self._is_dragging = False try: self.canvas.config(cursor="fleur") # Or "hand2", indicates draggable except tk.TclError: pass # Ignore if canvas is gone def _on_mouse_drag(self, event: tk.Event): """ Handles mouse drag motion. Visually pans the currently displayed map image without redrawing tiles for smooth feedback. """ # MODIFIED: Check if all necessary drag state variables and dependencies are available. # WHY: Prevent errors during drag if state is inconsistent or libraries are missing. # HOW: Added checks. if (self._drag_start_x_canvas is None or self._drag_start_y_canvas is None or self._drag_start_center_lon is None or self._drag_start_center_lat is None or self._current_map_geo_bounds is None or self._map_pil_image is None or self._canvas_image_id is None or # Need canvas item ID for visual pan not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists() or not PIL_LIB_AVAILABLE_DRAWING): # Need PIL Image for shape, ImageTk for canvas item (checked by PIL_LIB_AVAILABLE_DRAWING) # If dragging couldn't start properly or dependencies are missing, reset cursor and return. if hasattr(self, 'canvas') and self.canvas is not None and self.canvas.winfo_exists(): try: self.canvas.config(cursor=""); except tk.TclError: pass # logger.debug("Cannot perform visual pan: drag state or dependencies missing.") # Too verbose return dx_pixel = event.x - self._drag_start_x_canvas dy_pixel = event.y - self._drag_start_y_canvas # Start dragging only if the mouse has moved beyond a small threshold. # This prevents small clicks from being interpreted as drags. drag_threshold = 5 # pixels if not self._is_dragging and (abs(dx_pixel) > drag_threshold or abs(dy_pixel) > drag_threshold): self._is_dragging = True # Cursor was already set on press, no need to set again here. # If dragging is active, visually move the canvas image item. # The canvas item position (0,0 originally) is the top-left corner of the image. # If the mouse drags right by dx, the image should move right by dx to follow the cursor. # The new canvas item position is (current_item_x + dx, current_item_y + dy). # Tkinter's `canvas.move(item_id, dx, dy)` does exactly this relative move. if self._is_dragging and self._canvas_image_id is not None and self.canvas.winfo_exists(): try: # Move the main map image item by the delta from the drag start position to the current mouse position. # The drag start position is fixed relative to the canvas origin. # The current mouse position is also relative to the canvas origin. # So, the delta (event.x - drag_start_x_canvas) is the required shift of the image's top-left corner # from its position at drag start, relative to the canvas origin (which was 0,0 for the image). # New image top-left X = 0 + (event.x - self._drag_start_x_canvas) # New image top-left Y = 0 + (event.y - self._drag_start_y_canvas) # The `canvas.move` method is relative to the *current* position of the item. # To move the item from its position at drag start (which was 0,0) to the target position: # Need to calculate the total displacement from (0,0) to (event.x - drag_start_x_canvas, event.y - drag_start_y_canvas). # This is equal to the total displacement from (drag_start_x_canvas, drag_start_y_canvas) to (event.x, event.y). # So, `canvas.move(item_id, event.x - previous_event.x, event.y - previous_event.y)` is correct for relative moves. # But here we have the *start* position. # A simpler visual pan implementation: just move the image by the delta from the *previous* mouse position. # This requires storing the previous mouse position in `_on_mouse_drag`. # Let's use the total displacement method for simplicity with the drag start point: # Get the current position of the canvas item. current_item_coords = self.canvas.coords(self._canvas_image_id) if len(current_item_coords) == 2: current_item_x, current_item_y = current_item_coords # Calculate the required total displacement from the original position (0,0) # The new desired position for the image top-left is (event.x - self._drag_start_x_canvas, event.y - self._drag_start_y_canvas). # The delta from the *current* position to the desired new position is # (desired_x - current_item_x, desired_y - current_item_y). desired_item_x = event.x - self._drag_start_x_canvas desired_item_y = event.y - self._drag_start_y_canvas delta_x_move = desired_item_x - current_item_x delta_y_move = desired_item_y - current_item_y # Move the canvas item self.canvas.move(self._canvas_image_id, delta_x_move, delta_y_move) else: logger.warning(f"Canvas item {self._canvas_image_id} has unexpected coordinate format: {current_item_coords}. Cannot perform visual pan.") self._is_dragging = False # Stop dragging if we can't move the item if hasattr(self, 'canvas') and self.canvas is not None and self.canvas.winfo_exists(): try: self.canvas.config(cursor=""); except tk.TclError: pass # Reset cursor return # Exit method except tk.TclError: logger.warning("TclError during canvas move (pan). Canvas gone.") # Stop dragging if canvas is gone self._is_dragging = False; self._drag_start_x_canvas = None; self._drag_start_y_canvas = None; self._drag_start_center_lon = None; self._drag_start_center_lat = None if hasattr(self, 'canvas') and self.canvas is not None and self.canvas.winfo_exists(): try: self.canvas.config(cursor=""); except tk.TclError: pass return # Exit method except Exception as e: logger.error(f"Error during visual pan (canvas move): {e}", exc_info=True) self._is_dragging = False # Stop dragging on error if hasattr(self, 'canvas') and self.canvas is not None and self.canvas.winfo_exists(): try: self.canvas.config(cursor=""); except tk.TclError: pass # Reset cursor return # Exit method def _on_mouse_button_release(self, event: tk.Event): """ Handle mouse button release after pan/drag. Finalizes the pan operation by calculating the new geographic center and triggering a map redraw (fetching correct tiles). """ # MODIFIED: Check if canvas exists before proceeding. # WHY: Prevent errors if canvas is destroyed. # HOW: Added check. if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): # Ensure cursor is reset if canvas is gone during event try: self.canvas.config(cursor=""); except tk.TclError: pass; return # Reset cursor regardless of drag state outcome try: self.canvas.config(cursor=""); except tk.TclError: pass # If dragging didn't actually happen (just a click), exit early if not self._is_dragging: # If it was a click (not a drag), maybe handle as a selection or simple click? # Right-clicks are handled by _on_right_click. Left clicks could be selection. # For now, just log if it was a non-drag left click release. # logger.debug(f"Left click released at ({event.x}, {event.y}) (not a drag).") # Too verbose self._drag_start_x_canvas = None self._drag_start_y_canvas = None self._drag_start_center_lon = None self._drag_start_center_lat = None # Clear drag state return # Exit if not a drag # --- Finalize the Pan operation --- # We need to calculate the new geographic position of the center # based on the total pixel drag from the drag start point (event.x, event.y) # relative to the drag start point on the canvas (self._drag_start_x_canvas, self._drag_start_y_canvas). # MODIFIED: Check for all necessary drag state variables and dependencies before calculating final pan. # WHY: Prevent errors if state is inconsistent or libraries are missing. # HOW: Added checks. # MODIFIED: Check for Pyproj and Mercantile specifically, as needed for coordinate conversions. # WHY: These are required for the calculations below. # HOW: Added checks. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING is checked here instead of PIL_IMAGE_LIB_AVAILABLE. # WHY: Use the variable defined by the try/except block at the start of the module. # HOW: Replaced variable name. if (self._drag_start_x_canvas is None or self._drag_start_y_canvas is None or self._drag_start_center_lon is None or self._drag_start_center_lat is None or self._current_map_geo_bounds is None or self._map_pil_image is None or self._current_zoom is None or not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None): logger.warning("Cannot finalize pan: drag state, map context, or required libraries (Pillow, Mercantile, Pyproj) missing.") # Reset drag state self._is_dragging = False; self._drag_start_x_canvas = None; self._drag_start_y_canvas = None; self._drag_start_center_lon = None; self._drag_start_center_lat = None return # Exit method dx_pixel = event.x - self._drag_start_x_canvas dy_pixel = event.y - self._drag_start_y_canvas # Calculate the geographic coordinate that is located at the *center* of the canvas *after* the pan gesture. # The point that was at the center of the canvas at the *start* of the drag (when center was _drag_start_center_lat/lon) # is now visually moved by (dx_pixel, dy_pixel). # The new center is the geographic coordinate that is now at the canvas center. # A simpler way: calculate the geographic coordinate at the canvas pixel (center_x - dx_pixel, center_y - dy_pixel) # relative to the map position at the start of the drag. # This corresponds to shifting the geographic coordinate that was at the start pixel (drag_start_x_canvas, drag_start_y_canvas) # by the geographic equivalent of the pixel drag (dx_pixel, dy_pixel). # Let's use the inverse of the visual pan: if the image moved (dx, dy), the geographic center moved by (-dx, -dy) in pixel space. # Calculate the geographic coordinate corresponding to the canvas center pixel (width/2, height/2) # shifted by (-dx_pixel, -dy_pixel) from the origin. # Get current canvas dimensions current_canvas_width = self.canvas.winfo_width(); if current_canvas_width <= 1: current_canvas_width = self.canvas_width current_canvas_height = self.canvas.winfo_height(); if current_canvas_height <= 1: current_canvas_height = self.canvas_height if current_canvas_width <= 0 or current_canvas_height <= 0: logger.warning(f"Canvas dims zero/invalid ({current_canvas_width}x{current_canvas_height}) during pan finalize. Cannot calculate new center.") self._is_dragging = False; self._drag_start_x_canvas = None; self._drag_start_y_canvas = None; self._drag_start_center_lon = None; self._drag_start_center_lat = None # Reset drag state return # Exit if canvas dims are invalid # The pixel coordinate on the *stitched image* that should now be at the *center* of the canvas is # the pixel that was at the center of the canvas minus the total pixel drag from the start. # Center pixel coords relative to image top-left: (img_w / 2, img_h / 2) # Original image top-left was at canvas (0,0). After visual pan (dx, dy), image top-left is at (dx, dy) relative to canvas origin. # The point on the canvas at (canvas_width/2, canvas_height/2) corresponds to the pixel # (canvas_width/2 - dx_pixel, canvas_height/2 - dy_pixel) on the original image coordinate system. # Let's calculate the geographic coordinate of this pixel on the original stitched image. map_pixel_shape = self._map_pil_image.size # Dimensions of the stitched image target_pixel_x_on_image = (current_canvas_width / 2.0) - dx_pixel target_pixel_y_on_image = (current_canvas_height / 2.0) - dy_pixel # Convert this target pixel coordinate back to geographic coordinates using the stitched image's bounds # This geographic coordinate is the new center of the map view. # MODIFIED: Call the centralized map_utils.pixel_to_geo function. # WHY: Use the corrected and robust conversion utility. # HOW: Called the function with appropriate parameters. # MODIFIED: Check if map_utils.pixel_to_geo is available. # WHY: Avoid AttributeError. # HOW: Added check. pixel_to_geo_func = pixel_to_geo if pixel_to_geo_func is not None: new_center_lon, new_center_lat = pixel_to_geo_func( int(round(target_pixel_x_on_image)), # Convert to integer pixels int(round(target_pixel_y_on_image)), self._current_map_geo_bounds, # Pass the geo bounds of the stitched image map_pixel_shape # Pass the pixel shape of the stitched image ) else: logger.error("map_utils.pixel_to_geo is None. Cannot convert pixel coords for pan center.") new_center_lon, new_center_lat = None, None # Ensure they are None if conversion fails # Ensure the new center is valid before redrawing if new_center_lat is not None and new_center_lon is not None: # Clamp the new center latitude to the valid Mercator range new_center_lat = max(-MAX_LATITUDE_MERCATOR, min(MAX_LATITUDE_MERCATOR, new_center_lat)) # Wrap longitude around the +/- 180 meridian new_center_lon = (new_center_lon + 180.0) % 360.0 - 180.0 # Use coordinate decimal places constant for logging if available coord_log_precision = getattr(map_constants, 'COORDINATE_DECIMAL_PLACES', 5) # Default 5 if constant missing logger.info(f"Pan finalized. Total pixel drag ({dx_pixel}, {dy_pixel}). Calculated new center: ({new_center_lat:.{coord_log_precision}f}, {new_center_lon:.{coord_log_precision}f}) at zoom {self._current_zoom}.") # Recenter and redraw the map with the new geographic coordinates, maintaining the current zoom # The `recenter_and_redraw` method will fetch the appropriate tiles for this new view. # MODIFIED: Removed ensure_bbox_is_covered_dict parameter from recenter_and_redraw call. # WHY: Parameter no longer exists/used. # HOW: Removed argument. self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom) else: # Conversion failed (and logged warning by pixel_to_geo). logger.warning(f"Could not determine valid new center geographic coordinates from pan gesture pixel delta ({dx_pixel}, {dy_pixel}).") # Revert visual pan by redrawing at the original center? No, just log and keep current visual state. # The next interaction will trigger a new redraw. # Optionally update status bar. if self.app_controller and hasattr(self.app_controller, "update_semaphore_and_status"): try: self.app_controller.update_semaphore_and_status("WARNING", "Error finalizing pan. Map center not updated.") except Exception as e: logger.error(f"Error updating status for pan finalize fail: {e}", exc_info=False) # Reset drag state after finalizing pan self._is_dragging = False self._drag_start_x_canvas = None self._drag_start_y_canvas = None self._drag_start_center_lon = None self._drag_start_center_lat = None def _on_right_click(self, event: tk.Event): """Handle right-click to get geo coords and trigger context menu.""" # MODIFIED: Check if canvas exists before proceeding. # WHY: Prevent errors if canvas is destroyed. # HOW: Added check. if not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists(): return # MODIFIED: Check for necessary dependencies for geo conversion. # WHY: Avoid errors if Mercantile is missing. # HOW: Added check. # MODIFIED: Check if map_utils.pixel_to_geo is available. # WHY: Avoid AttributeError. # HOW: Added check. # MODIFIED: Check for Pyproj as well, needed by pixel_to_geo for some Mercator calculations (though mostly mercantile). # WHY: Safety check. # HOW: Added check. pixel_to_geo_func = pixel_to_geo if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None or self._current_map_geo_bounds is None or self._map_pil_image is None or pixel_to_geo_func is None: logger.warning("Cannot get geo coords for right-click: Mercantile, Pyproj, pixel-to-geo function, or map context missing.") # Update status bar to indicate click wasn't processed fully if self.app_controller and hasattr(self.app_controller, "update_semaphore_and_status"): try: self.app_controller.update_semaphore_and_status("WARNING", "Cannot get map coordinates.") except Exception as e: logger.error(f"Error updating status for click fail: {e}", exc_info=False) return # Exit if prerequisites are missing # Get geographic coordinate at the clicked pixel using the current map bounds and image shape. # MODIFIED: Call the centralized map_utils.pixel_to_geo function. # WHY: Use the corrected and robust conversion utility. # HOW: Called the function with appropriate parameters. map_pixel_shape = self._map_pil_image.size # Dimensions of the currently displayed image geo_lon_at_mouse, geo_lat_at_mouse = pixel_to_geo_func( event.x, event.y, # Pixel coordinates of the click on the canvas self._current_map_geo_bounds, # Geo bounds of the currently displayed image map_pixel_shape # Pixel shape of the currently displayed image ) # If conversion was successful, trigger the controller's handler if geo_lon_at_mouse is not None and geo_lat_at_mouse is not None: # Use coordinate decimal places constant for logging if available coord_log_precision = getattr(map_constants, 'COORDINATE_DECIMAL_PLACES', 5) # Default 5 if constant missing logger.info(f"Right-click Geo: Lat={geo_lat_at_mouse:.{coord_log_precision}f}, Lon={geo_lon_at_mouse:.{coord_log_precision}f}") # Call the controller method to handle the right-click, passing geo coords and screen coords for menu # MODIFIED: Check if app_controller and its handler method exist before calling. # WHY: Prevent AttributeError. # HOW: Added hasattr checks. if self.app_controller and hasattr(self.app_controller, "on_map_right_click"): try: # Pass the geographic coordinates and the screen coordinates (root window coordinates) # Screen coordinates are needed by MainWindow to position the context menu. self.app_controller.on_map_right_click( geo_lat_at_mouse, geo_lon_at_mouse, self.canvas.winfo_rootx() + event.x, # Screen X coord self.canvas.winfo_rooty() + event.y # Screen Y coord ) except Exception as e: logger.error(f"Error calling controller right-click handler: {e}", exc_info=False) else: # pixel_to_geo logged a warning if it failed. # logger.warning(f"Geo-to-pixel failed for right-click at ({event.x}, {event.y}).") # Logged by pixel_to_geo # Update status bar if self.app_controller and hasattr(self.app_controller, "update_semaphore_and_status"): try: self.app_controller.update_semaphore_and_status("WARNING", "Could not convert click location to coordinates.") except Exception as e: logger.error(f"Error updating status for click conversion fail: {e}", exc_info=False) def update_flights_on_map(self, flight_states: List[CanonicalFlightState]): """ Receives a list of CanonicalFlightState objects to be displayed on the map. Stores them and triggers a redraw of the map canvas, which will draw the flights on top of the current base map image if they fall within its bounds. """ # MODIFIED: Added check for CanonicalFlightState availability. # WHY: Prevent errors if the model class is not imported. # HOW: Added check. if not CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING: logger.warning("MapCanvasManager: CanonicalFlightState not available. Cannot update flights for display.") return # Exit if the model is not available logger.info(f"MapCanvasManager: Received {len(flight_states)} flight states for display.") # Store the list of flights. The _redraw_canvas_content method will use this list. self._current_flights_to_display = flight_states # Trigger a redraw of the canvas to update the flight overlay. # The redraw will use the currently stored base map image and its bounds. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING before redrawing. # WHY: _redraw_canvas_content relies heavily on PIL. # HOW: Added check. # MODIFIED: Ensure we have a base map image and its bounds before redrawing flights. # WHY: Flights are drawn on top of the base map. If the base map isn't ready, drawing flights will fail. # HOW: Added check for _map_pil_image and _current_map_geo_bounds. # MODIFIED: Ensure ImageDraw and ImageFont are available as well, needed by _redraw_canvas_content. # WHY: These are used for drawing overlays. # HOW: Added checks. # MODIFIED: Ensure map_drawing module is available. # WHY: _redraw_canvas_content relies on map_drawing helper functions. # HOW: Added check. if PIL_LIB_AVAILABLE_DRAWING and Image is not None and ImageDraw is not None and ImageFont is not None and map_drawing is not None and self._map_pil_image is not None and self._current_map_geo_bounds is not None: # Check if map drawing dependencies are available before redrawing # This is already done inside _redraw_canvas_content, but a check here can give earlier feedback. self._redraw_canvas_content() # Trigger a full redraw including the updated flights else: # If base map or drawing dependencies are not ready, the flights cannot be drawn immediately. Log a warning. # The flights are stored, so they will be drawn on the map the next time # _redraw_canvas_content is called (e.g., after a pan/zoom/resize or initial load). # Log specific reasons for skipping immediate redraw. if not PIL_LIB_AVAILABLE_DRAWING: logger.warning("Skipping immediate flight redraw: Pillow not available.") elif Image is None: logger.warning("Skipping immediate flight redraw: Pillow Image class is None.") elif ImageDraw is None: logger.warning("Skipping immediate flight redraw: Pillow ImageDraw is None.") elif ImageFont is None: logger.warning("Skipping immediate flight redraw: Pillow ImageFont is None.") elif map_drawing is None: logger.warning("Skipping immediate flight redraw: map_drawing module is None.") elif self._map_pil_image is None: logger.warning("Skipping immediate flight redraw: Base map image is None.") elif self._current_map_geo_bounds is None: logger.warning("Skipping immediate flight redraw: Map geo bounds are None.") def get_current_map_info(self) -> Dict[str, Any]: """ Returns a dictionary containing information about the currently displayed map view, including center coordinates, zoom level, geographic bounds of the displayed image, canvas dimensions, calculated geographic size in km, and the target BBox input. """ # Calculate the geographic size of the currently displayed map image in km map_size_km_w: Optional[float] = None map_size_km_h: Optional[float] = None # MODIFIED: Check if pyproj is available and geo bounds exist before calculating size. # WHY: Prevent errors if dependencies or map context is missing. # HOW: Added checks. # MODIFIED: Check if calculate_geographic_bbox_size_km is available from map_utils. # WHY: Avoid AttributeError. # HOW: Added check. size_calc_func = calculate_geographic_bbox_size_km if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None and self._current_map_geo_bounds is not None and size_calc_func is not None: try: # calculate_geographic_bbox_size_km expects (W, S, E, N) size_km_tuple = size_calc_func(self._current_map_geo_bounds) if size_km_tuple: map_size_km_w, map_size_km_h = size_km_tuple else: logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.") except Exception as e: logger.error(f"Error calc current map geo size: {e}", exc_info=False) elif not (PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None): # logger.debug("Pyproj not available, cannot calculate map size in km.") # Too verbose pass # Cannot calculate size if pyproj is missing elif self._current_map_geo_bounds is None: # logger.debug("Current map geo bounds not available, cannot calculate size in km.") # Too verbose pass # Cannot calculate size if bounds are missing elif size_calc_func is None: logger.error("map_utils.calculate_geographic_bbox_size_km function is None. Cannot calculate map size.") # Prepare the info dictionary info = { "center_lat": self._current_center_lat, # Geographic center of the current view "center_lon": self._current_center_lon, # Geographic center of the current view "zoom": self._current_zoom, # Current zoom level "geo_bounds": self._current_map_geo_bounds, # Geographic bounds of the *stitched image* on display "canvas_width": self.canvas_width, # Stored canvas size (updated after debounced resize) "canvas_height": self.canvas_height, # Stored canvas size "map_size_km_w": map_size_km_w, # Calculated geographic width in km "map_size_km_h": map_size_km_h, # Calculated geographic height in km # MODIFIED: Add the target BBox input for display/info purposes. # WHY: Useful to show the current monitoring/input area BBox in the info panel or elsewhere. # HOW: Added attribute to the info dict. "target_bbox_input": self._target_bbox_input, # The BBox from GUI inputs / Monitoring Area (used for outline) } return info # --- Interaction methods called by Controller (GUI Thread) --- # MODIFIED: Removed show_map_context_menu and _center_map_at_coords from MapCanvasManager. # WHY: These methods were placeholders and the logic belongs in the AppController # or a GUI-specific handler in MainWindow. The AppController should # call methods like `fit_view_to_bbox` or `recenter_and_redraw` on the # map manager, rather than the map manager having UI-specific methods like `show_map_context_menu`. # HOW: Deleted these methods. The logic for handling the context menu and # recenter request is now handled by the AppController's `on_map_right_click` # and a new `recenter_map_at_coords` method in AppController. # def show_map_context_menu(...): ... (Removed) # def _center_map_at_coords(...): ... (Removed) # MODIFIED: Added public recenter_map_at_coords method. # WHY: Allows the Controller to instruct the map manager to recenter the map view # at a specific geographic point while keeping the current zoom level. # This is the logic extracted from the removed _center_map_at_coords. # HOW: Defined the method, calling the internal recenter_and_redraw method. def recenter_map_at_coords(self, lat: float, lon: float): """ Recenter the map view at the specified geographic coordinates while keeping the current zoom level. Called by the Controller (e.g., from context menu action). Triggers a redraw of the map. """ # MODIFIED: Use coordinate decimal places constant for logging if available. # WHY: Consistency in logging format. # HOW: Used getattr for safe access. coord_log_precision = getattr(map_constants, 'COORDINATE_DECIMAL_PLACES', 5) # Default 5 if constant missing logger.info(f"Request to recenter map @ Geo ({lat:.{coord_log_precision}f}, {lon:.{coord_log_precision}f}) while keeping current zoom.") # MODIFIED: Check for necessary dependencies and current state. # WHY: Ensure the map can be recentered. # HOW: Added checks. # MODIFIED: Check for Pyproj and Mercantile specifically, as needed by recenter_and_redraw utility calls. # WHY: These are required for the calculations within recenter_and_redraw. # HOW: Added checks. # MODIFIED: Ensure PIL_LIB_AVAILABLE_DRAWING is checked here instead of PIL_IMAGE_LIB_AVAILABLE. # WHY: Use the variable defined by the try/except block at the start of the module. # HOW: Replaced variable name. if (self._current_zoom is None or not hasattr(self, 'canvas') or self.canvas is None or not self.canvas.winfo_exists() or not PIL_LIB_AVAILABLE_DRAWING or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None): logger.warning("Map context or required libraries missing. Cannot recenter map.") # Optionally update status? if self.app_controller and hasattr(self.app_controller, "update_semaphore_and_status"): try: self.app_controller.update_semaphore_and_status("WARNING", "Cannot recenter map (map not ready).") except Exception as e: logger.error(f"Error updating status for recenter fail: {e}", exc_info=False) return # Exit if prerequisites are not met # Call the internal redraw method with the new center coordinates and the current zoom level. # This will trigger fetching new tiles for the view centered at (lat, lon) at _current_zoom. # MODIFIED: Removed ensure_bbox_is_covered_dict parameter from recenter_and_redraw call. # WHY: Parameter no longer exists/used in recenter_and_redraw. # HOW: Removed argument. self.recenter_and_redraw(lat, lon, self._current_zoom)