# flightmonitor/map/map_canvas_manager.py import tkinter as tk try: from PIL import Image, ImageTk, ImageDraw, ImageFont PIL_IMAGE_LIB_AVAILABLE = True except ImportError: Image = None ImageTk = None ImageDraw = None ImageFont = None PIL_IMAGE_LIB_AVAILABLE = False import logging logging.error("MapCanvasManager: Pillow not found. Map disabled.") try: import pyproj PYPROJ_MODULE_LOCALLY_AVAILABLE = True except ImportError: pyproj = None PYPROJ_MODULE_LOCALLY_AVAILABLE = False import logging logging.warning("MapCanvasManager: 'pyproj' not found. Calc impaired.") try: import mercantile MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError: mercantile = None MERCANTILE_MODULE_LOCALLY_AVAILABLE = False import logging logging.error("MapCanvasManager: 'mercantile' not found. Conversions impaired.") import math from typing import Optional, Tuple, List, Dict, Any from flightmonitor.map import map_constants from flightmonitor.data import config as fm_config from flightmonitor.data.common_models import CanonicalFlightState from flightmonitor.map.map_services import BaseMapService, OpenStreetMapService from flightmonitor.map.map_manager import MapTileManager from flightmonitor.map.map_utils import ( get_tile_ranges_for_bbox, calculate_geographic_bbox_size_km, calculate_geographic_bbox_from_pixel_size_and_zoom, deg_to_dms_string, _is_valid_bbox_dict, _pixel_to_geo, calculate_meters_per_pixel ) from . import map_drawing try: from ..utils.logger import get_logger except ImportError: import logging get_logger = logging.getLogger logging.warning("MapCanvasManager using fallback logger.") logger = get_logger(__name__) CANVAS_SIZE_HARD_FALLBACK_PX = 800 MAP_TILE_CACHE_DIR_HARD_FALLBACK = "flightmonitor_tile_cache_fallback" RESIZE_DEBOUNCE_DELAY_MS = 50 class MapCanvasManager: def __init__( self, app_controller: Any, tk_canvas: tk.Canvas, initial_bbox_dict: Dict[str, float], ): logger.info("Initializing MapCanvasManager...") if not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: critical_msg = "MapCanvasManager critical dependencies missing: Pillow or Mercantile. Map disabled." logger.critical(critical_msg) raise ImportError(critical_msg) self.app_controller = app_controller self.canvas = tk_canvas self.canvas_width = self.canvas.winfo_width() if self.canvas_width <= 1: self.canvas_width = getattr(fm_config, 'DEFAULT_CANVAS_WIDTH', CANVAS_SIZE_HARD_FALLBACK_PX) self.canvas_height = self.canvas.winfo_height() if self.canvas_height <= 1: self.canvas_height = getattr(fm_config, 'DEFAULT_CANVAS_HEIGHT', CANVAS_SIZE_HARD_FALLBACK_PX) 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.") self._current_center_lat: Optional[float] = None self._current_center_lon: Optional[float] = None self._current_zoom: int = map_constants.DEFAULT_INITIAL_ZOOM self._current_map_geo_bounds: Optional[Tuple[float, float, float, float]] = None self._map_pil_image: Optional[Image.Image] = None self._map_photo_image: Optional[ImageTk.PhotoImage] = None self._canvas_image_id: Optional[int] = None self.map_service: BaseMapService = OpenStreetMapService() cache_dir = getattr(fm_config, "MAP_TILE_CACHE_DIR", MAP_TILE_CACHE_DIR_HARD_FALLBACK) 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}'.") self._target_bbox_input: Optional[Dict[str, float]] = None self._active_api_bbox_for_flights: Optional[Dict[str, float]] = None # Let controller manage this state for data fetching self._current_flights_to_display: List[CanonicalFlightState] = [] self._resize_debounce_job_id: Optional[str] = None self._zoom_debounce_job_id: Optional[str] = None if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict): self._target_bbox_input = initial_bbox_dict.copy() self.update_map_view_for_bbox(initial_bbox_dict, preserve_current_zoom_if_possible=False) else: logger.warning(f"Invalid initial_bbox_dict: {initial_bbox_dict}. Using default fallback.") default_bbox = { "lat_min": fm_config.DEFAULT_BBOX_LAT_MIN, "lon_min": fm_config.DEFAULT_BBOX_LON_MIN, "lat_max": fm_config.DEFAULT_BBOX_LAT_MAX, "lon_max": fm_config.DEFAULT_BBOX_LON_MAX, } if _is_valid_bbox_dict(default_bbox): self._target_bbox_input = default_bbox.copy() self.set_target_bbox(default_bbox) else: logger.critical(f"Default fallback BBox invalid: {default_bbox}. Cannot init map view.") self._target_bbox_input = None self._current_center_lat = None self._current_center_lon = None self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM self._current_map_geo_bounds = None self.clear_map_display() if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): self.app_controller.update_general_map_info() self._setup_event_bindings() logger.info(f"MapCanvasManager initialized for canvas size {self.canvas_width}x{self.canvas_height}.") def _setup_event_bindings(self): self.canvas.bind("", self._on_canvas_resize) self.canvas.bind("", self._on_mouse_wheel_windows_macos) self.canvas.bind("", self._on_mouse_wheel_linux) self.canvas.bind("", self._on_mouse_wheel_linux) self.canvas.bind("", self._on_mouse_button_press) self.canvas.bind("", self._on_mouse_drag) self.canvas.bind("", self._on_mouse_button_release) self.canvas.bind("", self._on_right_click) self._drag_start_x_canvas: Optional[int] = None self._drag_start_y_canvas: Optional[int] = None self._drag_start_center_lon: Optional[float] = None self._drag_start_center_lat: Optional[float] = None self._is_dragging: bool = False def _on_canvas_resize(self, event: tk.Event): new_width = event.width new_height = event.height if new_width > 1 and new_height > 1 and (self.canvas_width != new_width or self.canvas_height != new_height): if self._resize_debounce_job_id: try: self.canvas.after_cancel(self._resize_debounce_job_id) except tk.TclError: pass except Exception as e: logger.error(f"Error cancelling resize redraw job: {e}", exc_info=False) finally: self._resize_debounce_job_id = None if self._zoom_debounce_job_id: try: self.canvas.after_cancel(self._zoom_debounce_job_id) except tk.TclError: pass except Exception as e: logger.error(f"Error cancelling zoom job on resize: {e}", exc_info=False) finally: self._zoom_debounce_job_id = None self._resize_debounce_job_id = self.canvas.after( RESIZE_DEBOUNCE_DELAY_MS, self._perform_resize_redraw, new_width, new_height ) def _perform_resize_redraw(self, width: int, height: int): self._resize_debounce_job_id = None if not self.canvas.winfo_exists(): return logger.info(f"Performing debounced resize redraw for dimensions {width}x{height}.") self.canvas_width = width self.canvas_height = height if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input): logger.debug("Refitting map view to target BBox after resize.") self.update_map_view_for_bbox( self._target_bbox_input, preserve_current_zoom_if_possible=False ) elif ( self._current_center_lat is not None and self._current_center_lon is not None and self._current_zoom is not None ): logger.debug("Recentering map view at current geo center after resize.") self.recenter_and_redraw( self._current_center_lat, self._current_center_lon, self._current_zoom, ensure_bbox_is_covered_dict=None ) else: logger.warning("No valid BBox or geo center after resize. Resetting to default view.") default_bbox = { "lat_min": fm_config.DEFAULT_BBOX_LAT_MIN, "lon_min": fm_config.DEFAULT_BBOX_LON_MIN, "lat_max": fm_config.DEFAULT_BBOX_LAT_MAX, "lon_max": fm_config.DEFAULT_BBOX_LON_MAX, } if _is_valid_bbox_dict(default_bbox): self._target_bbox_input = default_bbox.copy() self.set_target_bbox(default_bbox) else: logger.critical("Default fallback BBox invalid after resize. Cannot set map view.") self._target_bbox_input = None self.clear_map_display() def set_target_bbox(self, new_bbox_dict: Dict[str, float]): logger.info(f"MapCanvasManager: New target BBox requested: {new_bbox_dict}") if new_bbox_dict and _is_valid_bbox_dict(new_bbox_dict): self._target_bbox_input = new_bbox_dict.copy() self.update_map_view_for_bbox(self._target_bbox_input, preserve_current_zoom_if_possible=False) else: logger.warning(f"Invalid/empty new_bbox_dict provided: {new_bbox_dict}. Clearing target BBox.") self._target_bbox_input = None if self._current_center_lat is not None and self._current_center_lon is not None and self._current_zoom is not None: self.recenter_and_redraw(self._current_center_lat, self._current_center_lon, self._current_zoom, ensure_bbox_is_covered_dict=None) else: self.clear_map_display() if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): self.app_controller.update_general_map_info() def update_map_view_for_bbox( self, target_bbox_dict: Dict[str, float], preserve_current_zoom_if_possible: bool = False, ): if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict): logger.warning("update_map_view_for_bbox called with invalid/no target BBox.") return lat_min, lon_min, lat_max, lon_max = target_bbox_dict["lat_min"], target_bbox_dict["lon_min"], target_bbox_dict["lat_max"], target_bbox_dict["lon_max"] view_center_lat = (lat_min + lat_max) / 2.0 view_center_lon = (lon_min + lon_max) / 2.0 if lon_min > lon_max: view_center_lon = (lon_min + (lon_max + 360.0)) / 2.0 if view_center_lon >= 180.0: view_center_lon -= 360.0 logger.info(f"Updating map view for target BBox. Center: ({view_center_lat:.4f}, {view_center_lon:.4f}), Preserve zoom: {preserve_current_zoom_if_possible}") zoom_to_use = self._current_zoom if not preserve_current_zoom_if_possible: calculated_zoom_for_target_bbox = map_constants.DEFAULT_INITIAL_ZOOM current_canvas_width = self.canvas_width current_canvas_height = self.canvas_height if current_canvas_width <= 0 or current_canvas_height <= 0: current_canvas_width = self.canvas.winfo_width() current_canvas_height = self.canvas.winfo_height() if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None: try: geod = pyproj.Geod(ellps="WGS84") center_lat_for_geod = max(-89.9, min(89.9, view_center_lat)) _, _, width_m = geod.inv(lon_min, center_lat_for_geod, lon_max, center_lat_for_geod) width_m = abs(width_m) center_lon_for_geod = max(-179.9, min(179.9, view_center_lon)) _, _, height_m = geod.inv(center_lon_for_geod, lat_min, center_lon_for_geod, lat_max) height_m = abs(height_m) if current_canvas_width <= 0 or current_canvas_height <= 0: logger.warning(f"Canvas dims zero/invalid ({current_canvas_width}x{current_canvas_height}). Cannot calc zoom to fit BBox. Using default.") zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: req_res_h = float('inf'); if current_canvas_height > 0 and height_m > 1e-9: req_res_h = height_m / current_canvas_height req_res_w = float('inf'); resolution_at_center_lat = calculate_meters_per_pixel(center_lat_for_geod, self._current_zoom if self._current_zoom is not None else map_constants.DEFAULT_INITIAL_ZOOM, self.tile_manager.tile_size) if resolution_at_center_lat is not None and current_canvas_width > 0 and width_m > 1e-9 and resolution_at_center_lat > 1e-9: # Estimate the required resolution based on the BBox width and canvas width, adjusted for latitude scale # This is a simplified estimate. A more accurate method involves projecting the BBox corners. # For now, assume resolution at center lat is representative. req_res_w = width_m / current_canvas_width # This is the ground distance per pixel at center lat needed to fit width target_res_m_px = float('inf'); if req_res_h < float('inf') and req_res_w < float('inf'): # Take the maximum resolution needed to fit both dimensions target_res_m_px = max(req_res_h, req_res_w) elif req_res_h < float('inf'): target_res_m_px = req_res_h elif req_res_w < float('inf'): target_res_m_px = req_res_w else: logger.warning("Could not determine valid target res. Using default zoom."); target_res_m_px = 0 if target_res_m_px > 1e-9 and math.isfinite(target_res_m_px) and resolution_at_center_lat is not None and resolution_at_center_lat > 1e-9: # Calculate required zoom based on target resolution and resolution at zoom 0 # Resolution at zoom 0 (equator): EARTH_CIRCUMFERENCE_METERS / TILE_PIXEL_SIZE # Resolution at zoom Z (latitude): (EARTH_CIRCUMFERENCE_METERS * cos(lat)) / (TILE_PIXEL_SIZE * 2^Z) # req_res = res_at_0 * cos(lat) / 2^Z # 2^Z = res_at_0 * cos(lat) / req_res # Z = log2(res_at_0 * cos(lat) / req_res) res_at_zoom_0_equator = (40075016.686) / self.tile_manager.tile_size clamped_center_lat_for_cos = max(-85.05, min(85.05, view_center_lat)) cos_lat = math.cos(math.radians(clamped_center_lat_for_cos)) if cos_lat > 1e-9 and target_res_m_px > 1e-9: term = (res_at_zoom_0_equator * cos_lat) / target_res_m_px if term > 1e-9: precise_zoom = math.log2(term) calculated_zoom_for_target_bbox = int(round(precise_zoom)) 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)) # Adjust zoom down if the calculated bounds at this zoom are still smaller than the target BBox # This could happen due to tile alignment/rounding. # A more complex check would be needed here. For now, rely on the BBox being ensured coverage. logger.info(f"Calc zoom {zoom_to_use} to fit BBox (precise {precise_zoom:.2f}).") else: logger.warning(f"Cannot calc zoom: log2 term non-positive. Using default ({map_constants.DEFAULT_INITIAL_ZOOM})."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: logger.warning(f"Cannot calc zoom: cosine/req_res problematic. Using default ({map_constants.DEFAULT_INITIAL_ZOOM})."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: logger.warning(f"Cannot calc zoom: target res invalid ({target_res_m_px:.2e}). Using default ({map_constants.DEFAULT_INITIAL_ZOOM})."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM except Exception as e: logger.error(f"Error calculating zoom for BBox: {e}. Using default zoom.", exc_info=True); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM else: logger.warning("Pyproj not available. Using default zoom."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM bbox_to_ensure = target_bbox_dict # Always ensure the *original requested* BBox is covered when setting view for BBox. self.recenter_and_redraw(view_center_lat, view_center_lon, zoom_to_use, ensure_bbox_is_covered_dict=bbox_to_ensure) def recenter_and_redraw( self, center_lat: float, center_lon: float, zoom_level: int, ensure_bbox_is_covered_dict: Optional[Dict[str, float]] = None, ): logger.info(f"Recentering map. Center: ({center_lat:.4f}, {center_lon:.4f}), Zoom: {zoom_level}. Ensure BBox Covered: {'Yes' if ensure_bbox_is_covered_dict else 'No'}") if not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: logger.error("Pillow or Mercantile not available. Cannot recenter/redraw.") self._clear_canvas_display(); return self._current_center_lat = center_lat self._current_center_lon = center_lon max_zoom_limit = self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK self._current_zoom = max(map_constants.MIN_ZOOM_LEVEL, min(zoom_level, max_zoom_limit)) current_canvas_width = self.canvas_width current_canvas_height = self.canvas_height 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(); return map_fetch_geo_bounds_for_tiles_tuple: Optional[Tuple[float, float, float, float]] = None # Calculate the geographic area that the *canvas* will cover at the target center/zoom canvas_fill_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom( self._current_center_lat, self._current_center_lon, current_canvas_width, current_canvas_height, self._current_zoom, self.tile_manager.tile_size, ) # The area to fetch tiles for should be based on the canvas bounds. if canvas_fill_bbox: map_fetch_geo_bounds_for_tiles_tuple = canvas_fill_bbox logger.debug(f"Tile fetching will be based on canvas_fill_bbox: {map_fetch_geo_bounds_for_tiles_tuple} at zoom {self._current_zoom}") if ensure_bbox_is_covered_dict and _is_valid_bbox_dict(ensure_bbox_is_covered_dict): user_bb = ensure_bbox_is_covered_dict # Verification: check if user_bb is contained in canvas_fill_bbox (with tolerance) # This check is useful during BBox fitting to see if the calculated zoom/center worked. cf_w, cf_s, cf_e, cf_n = canvas_fill_bbox contained = ( user_bb["lon_min"] >= cf_w - 1e-5 and user_bb["lat_min"] >= cf_s - 1e-5 and user_bb["lon_max"] <= cf_e + 1e-5 and user_bb["lat_max"] <= cf_n + 1e-5 ) if not contained: logger.warning(f"User BBox {user_bb} may not be fully contained within calculated canvas_fill_bbox {canvas_fill_bbox} at zoom {self._current_zoom}. Some parts might be clipped or outside the exact fetched tile area.") else: logger.debug(f"User BBox {user_bb} appears to be contained within canvas_fill_bbox {canvas_fill_bbox}.") else: logger.error("Failed to calculate canvas_fill_bbox. Cannot determine fetch bounds.") self._clear_canvas_display(); return if map_fetch_geo_bounds_for_tiles_tuple is None: logger.critical("map_fetch_geo_bounds_for_tiles_tuple is None after BBox logic. Aborting redraw.") self._clear_canvas_display(); return tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bounds_for_tiles_tuple, self._current_zoom) if not tile_xy_ranges: logger.error(f"Failed to get tile ranges for {map_fetch_geo_bounds_for_tiles_tuple} at zoom {self._current_zoom}. Cannot draw.") self._clear_canvas_display() if PIL_IMAGE_LIB_AVAILABLE and Image is not None and ImageDraw is not None and ImageFont is not None: try: placeholder_img = Image.new("RGB", (current_canvas_width, current_canvas_height), map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB) if ImageDraw: draw = ImageDraw.Draw(placeholder_img) self.tile_manager._draw_text_on_placeholder(draw, placeholder_img.size, "Map Error\nCannot get tiles.") if self.canvas.winfo_exists(): self._clear_canvas_display() 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.") except Exception as e_placeholder: logger.error(f"Failed to draw tile range error placeholder: {e_placeholder}", exc_info=True) return logger.debug(f"Tile ranges for current view: X={tile_xy_ranges[0]}, Y={tile_xy_ranges[1]}") stitched_map_pil = self.tile_manager.stitch_map_image(self._current_zoom, tile_xy_ranges[0], tile_xy_ranges[1]) if not stitched_map_pil: logger.error("Failed to stitch map image.") self._clear_canvas_display(); return self._current_map_geo_bounds = self.tile_manager._get_bounds_for_tile_range(self._current_zoom, tile_xy_ranges) if not self._current_map_geo_bounds: logger.warning("Could not determine actual stitched map geo bounds. Using fetch bounds as fallback for display info.") self._current_map_geo_bounds = map_fetch_geo_bounds_for_tiles_tuple # Use fetch bounds as fallback logger.debug(f"Actual stitched map geo bounds: {self._current_map_geo_bounds}") self._map_pil_image = stitched_map_pil if self._map_pil_image and PIL_IMAGE_LIB_AVAILABLE: self._redraw_canvas_content() # MODIFIED: Call update_general_map_info after redraw. # WHY: To update the info panel with the new map bounds, center, zoom. # HOW: Added the call. if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): self.app_controller.update_general_map_info() # MODIFIED: Call update_general_map_info even if PIL image not available, to update with N/A. # WHY: Keep info panel updated even on failure. # HOW: Added the call in the else branch. elif self.app_controller and hasattr(self.app_controller, "update_general_map_info"): self.app_controller.update_general_map_info() def _redraw_canvas_content(self): logger.debug("MapCanvasManager: _redraw_canvas_content called.") # MODIFIED: Use the correct PIL availability flag. # WHY: Use the correct flag defined in this module. # HOW: Changed the variable name. if not PIL_IMAGE_LIB_AVAILABLE or Image is None or ImageDraw is None: logger.warning("_redraw_canvas_content: Pillow/ImageDraw not available. Cannot draw.") self._clear_canvas_display(); return logger.debug("Clearing previous canvas display.") self._clear_canvas_display() if self._map_pil_image is None or self._current_map_geo_bounds is None: logger.warning("No base map image or geo bounds to draw overlays on. Canvas cleared, skipping overlay drawing.") return logger.debug("Base map image and geo bounds available. Proceeding with overlay drawing.") if Image: image_to_draw_on = self._map_pil_image.copy() else: logger.error("Pillow Image class missing during image copy. Cannot draw overlays.") return image_to_draw_on_pixel_shape = image_to_draw_on.size if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input): logger.debug(f"Drawing target BBox: {self._target_bbox_input}") user_bbox_to_draw_wesn = (self._target_bbox_input["lon_min"], self._target_bbox_input["lat_min"], self._target_bbox_input["lon_max"], self._target_bbox_input["lat_max"]) try: image_to_draw_on = map_drawing.draw_area_bounding_box( image_to_draw_on, user_bbox_to_draw_wesn, self._current_map_geo_bounds, image_to_draw_on_pixel_shape, color=map_constants.AREA_BOUNDARY_COLOR, thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX, ) logger.debug("Target BBox drawn.") except Exception as e: logger.error(f"Error drawing target BBox: {e}", exc_info=False) flights_drawn_count = 0 logger.debug(f"Drawing {len(self._current_flights_to_display)} flights on map.") if self._current_flights_to_display: if ImageDraw is not None: draw = ImageDraw.Draw(image_to_draw_on) current_zoom_for_font = self._current_zoom if self._current_zoom is not None else map_constants.DEM_TILE_LABEL_BASE_ZOOM label_font = map_drawing._load_label_font(map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE + (current_zoom_for_font - map_constants.DEM_TILE_LABEL_BASE_ZOOM)) if label_font is None: logger.warning("Could not load label font. Flight labels will not be drawn.") for flight in self._current_flights_to_display: pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map( flight.latitude, flight.longitude, self._current_map_geo_bounds, image_to_draw_on_pixel_shape, ) if pixel_coords: try: map_drawing._draw_single_flight(draw=draw, pixel_coords=pixel_coords, flight_state=flight, label_font=label_font) flights_drawn_count += 1 except Exception as e: logger.error(f"Error drawing flight {flight.icao24} (via MapDrawing helper): {e}", exc_info=False) elif flight.latitude is not None and flight.longitude is not None: logger.debug(f"Skipping draw for flight {flight.icao24}: Geo ({flight.latitude:.4f}, {flight.longitude:.4f}) could not be converted to pixel.") else: logger.warning("ImageDraw not available, skipping drawing flights.") logger.debug(f"Finished drawing flights. Total drawn: {flights_drawn_count}") try: if ImageTk: self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on) logger.debug("Created PhotoImage from updated PIL image.") else: logger.error("Pillow ImageTk missing. Cannot create PhotoImage for canvas.") self._clear_canvas_display(); return except Exception as e: logger.error(f"Failed to create PhotoImage from updated PIL image: {e}", exc_info=True); self._clear_canvas_display(); return if self.canvas.winfo_exists(): try: self._canvas_image_id = self.canvas.create_image(0, 0, anchor=tk.NW, image=self._map_photo_image) logger.debug("Canvas redrawn with new image item.") except tk.TclError as e: logger.warning(f"TclError drawing canvas image: {e}. GUI likely gone.", exc_info=False) self._canvas_image_id = None except Exception as e: logger.error(f"Unexpected error drawing canvas image: {e}", exc_info=True); self._canvas_image_id = None else: logger.debug("_redraw_canvas_content: Canvas does not exist, skipping final image draw.") if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): try: # MODIFIED: Call update_general_map_info after drawing is complete. # WHY: To update the info panel with the latest map state including flight count. # HOW: Moved the call here. self.app_controller.update_general_map_info() logger.debug("Requested general map info update via controller after redraw.") except Exception as e_update_info: logger.error(f"Error requesting general map info update via controller after redraw: {e_update_info}", exc_info=False) def _clear_canvas_display(self): logger.debug("Clearing main canvas image item.") if self._canvas_image_id is not None and self.canvas.winfo_exists(): try: self.canvas.delete(self._canvas_image_id) except tk.TclError: logger.warning("TclError deleting canvas item. Item/canvas gone.") except Exception as e: logger.error(f"Error deleting canvas item {self._canvas_image_id}: {e}", exc_info=False) finally: self._canvas_image_id = None self._map_photo_image = None logger.debug("Main canvas image item cleared.") def clear_map_display(self): logger.info("MapCanvasManager: Clearing all map content.") self._clear_canvas_display() self._map_pil_image = None self._current_flights_to_display = [] self._current_map_geo_bounds = None # Don't reset zoom or center unless explicitly requested (e.g. new BBox) # self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM # self._current_center_lat = None # self._current_center_lon = None self._target_bbox_input = None # Clear target BBox when clearing display if self.canvas.winfo_exists(): try: self.canvas.delete("placeholder_text") except tk.TclError: logger.debug("TclError clearing placeholder tag.") except Exception as e: logger.error(f"Error clearing placeholder tag: {e}", exc_info=False) # MODIFIED: Call update_general_map_info after clearing map display. # WHY: To update the info panel to reflect that the map is cleared (N/A values). # HOW: Added the call. if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): self.app_controller.update_general_map_info() def _on_mouse_wheel_windows_macos(self, event: tk.Event): zoom_direction = 1 if event.delta > 0 else -1 self._handle_zoom(zoom_direction, event.x, event.y) def _on_mouse_wheel_linux(self, event: tk.Event): zoom_direction = 0 if event.num == 4: zoom_direction = 1 elif event.num == 5: zoom_direction = -1 if zoom_direction != 0: self._handle_zoom(zoom_direction, event.x, event.y) def _handle_zoom(self, zoom_direction: int, canvas_x: int, canvas_y: int): if self._current_zoom is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: logger.warning("Map context/libs missing. Cannot handle zoom.") return # Cancel any existing debounced zoom job if self._zoom_debounce_job_id: try: self.canvas.after_cancel(self._zoom_debounce_job_id) except tk.TclError: pass except Exception as e: logger.error(f"Error cancelling zoom job: {e}", exc_info=False) finally: self._zoom_debounce_job_id = None # Cancel any pending resize redraw job, as zoom implies a new view calculation. if self._resize_debounce_job_id: try: self.canvas.after_cancel(self._resize_debounce_job_id) except tk.TclError: pass except Exception as e: logger.error(f"Error cancelling resize job on zoom: {e}", exc_info=False) finally: self._resize_debounce_job_id = None # Schedule the actual zoom operation after a delay # Store zoom parameters to be used by _perform_zoom_redraw self._pending_zoom_direction = zoom_direction self._pending_zoom_canvas_x = canvas_x self._pending_zoom_canvas_y = canvas_y self._zoom_debounce_job_id = self.canvas.after( RESIZE_DEBOUNCE_DELAY_MS, # Use the same debounce delay as resize for consistency self._perform_zoom_redraw ) def _perform_zoom_redraw(self): self._zoom_debounce_job_id = None if not self.canvas.winfo_exists(): return zoom_direction = self._pending_zoom_direction canvas_x = self._pending_zoom_canvas_x canvas_y = self._pending_zoom_canvas_y if self._current_zoom is None or self._current_map_geo_bounds is None or self._map_pil_image is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: logger.warning("Map context/libs missing during debounced zoom. Cannot zoom.") return map_pixel_shape = self._map_pil_image.size # Use (width, height) geo_lon_at_mouse, geo_lat_at_mouse = _pixel_to_geo(canvas_x, canvas_y, self._current_map_geo_bounds, map_pixel_shape) # Pass (width, height) max_zoom_limit = self.map_service.max_zoom if self.map_service else map_constants.DEFAULT_MAX_ZOOM_FALLBACK new_zoom = max(map_constants.MIN_ZOOM_LEVEL, min(self._current_zoom + zoom_direction, max_zoom_limit)) if new_zoom != self._current_zoom: logger.info(f"Zoom changed from {self._current_zoom} to {new_zoom} (debounced).") 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 if target_center_lat is not None and target_center_lon is not None: # When zooming, we want to maintain the current target point (under mouse) at the same screen pixel. # Recenter based on the new zoom and the geo point at the mouse. # The recenter_and_redraw logic should handle calculating the new center needed to achieve this. # Alternatively, pass the desired new zoom and the point to center *on*. # The current recenter_and_redraw expects the *final* center. # Let's calculate the new center based on the zoom and mouse position. # This is a bit more complex than just centering on geo_lat_at_mouse, geo_lon_at_mouse, # as that would move the point under the mouse. # A simpler approach is to just recenter on the calculated geo point under the mouse at the new zoom. self.recenter_and_redraw(target_center_lat, target_center_lon, new_zoom, ensure_bbox_is_covered_dict=None) # No specific BBox to ensure coverage for during pan/zoom else: logger.warning("Could not determine zoom center during debounced zoom.") def _on_mouse_button_press(self, event: tk.Event): if not self.canvas.winfo_exists(): return self._drag_start_x_canvas, self._drag_start_y_canvas = event.x, event.y 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 geo center unknown. Cannot pan."); self._drag_start_center_lon, self._drag_start_center_lat = None, None self._is_dragging = False try: self.canvas.config(cursor="fleur") except tk.TclError: pass def _on_mouse_drag(self, event: tk.Event): 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 not self.canvas.winfo_exists() or self._current_zoom is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or self._map_pil_image is None): if self.canvas.winfo_exists(): try: self.canvas.config(cursor=""); except tk.TclError: pass return dx_pixel, dy_pixel = event.x - self._drag_start_x_canvas, event.y - self._drag_start_y_canvas drag_threshold = 5 if not self._is_dragging and (abs(dx_pixel) > drag_threshold or abs(dy_pixel) > drag_threshold): self._is_dragging = True # Optional: Provide visual feedback during drag by moving the canvas image # if self._canvas_image_id is not None: # self.canvas.coords(self._canvas_image_id, dx_pixel, dy_pixel) def _on_mouse_button_release(self, event: tk.Event): if not self.canvas.winfo_exists(): try: self.canvas.config(cursor=""); except tk.TclError: pass; return try: self.canvas.config(cursor=""); except tk.TclError: pass if not self._is_dragging: self._drag_start_x_canvas, self._drag_start_y_canvas, self._drag_start_center_lon, self._drag_start_center_lat = None, None, None, None # MODIFIED: Handle left-click when not dragging. # WHY: To allow users to click on the map to potentially select/identify objects. # HOW: Added logic to call a controller method. logger.debug(f"Map left-clicked at Canvas ({event.x}, {event.y}).") # Convert click pixel to geo coordinate if self._current_map_geo_bounds is not None and self._map_pil_image is not None and self._current_zoom is not None: clicked_lon, clicked_lat = _pixel_to_geo(event.x, event.y, self._current_map_geo_bounds, self._map_pil_image.size) # Pass (width, height) if clicked_lon is not None and clicked_lat is not None: logger.debug(f"Left-click Geo: Lat={clicked_lat:.5f}, Lon={clicked_lon:.5f}") # Delegate handling the click to the controller if self.app_controller and hasattr(self.app_controller, "on_map_left_click"): try: self.app_controller.on_map_left_click(clicked_lat, clicked_lon, event.x_root, event.y_root) except Exception as e: logger.error(f"Error calling controller left click handler: {e}", exc_info=False) else: logger.warning(f"Failed to convert left click pixel ({event.x},{event.y}) to geo coordinate.") else: logger.warning("Map context missing for left click geo conversion.") return 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._current_zoom is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or self._map_pil_image is None): logger.warning("Cannot finalize pan: drag state/context/libs missing.") 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 image position if it was moved during drag for visual feedback if self._canvas_image_id is not None and self.canvas.winfo_exists(): try: self.canvas.coords(self._canvas_image_id, 0, 0) except tk.TclError: pass return dx_pixel, dy_pixel = event.x - self._drag_start_x_canvas, event.y - self._drag_start_y_canvas # Reset image position if it was moved during drag for visual feedback if self._canvas_image_id is not None and self.canvas.winfo_exists(): try: self.canvas.coords(self._canvas_image_id, 0, 0) except tk.TclError: pass map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds current_canvas_width = self.canvas_width current_canvas_height = self.canvas_height if current_canvas_width <= 0 or current_canvas_height <= 0: logger.warning("Canvas dims zero/invalid. Cannot calc pan delta."); self._is_dragging = False; return # Calculate meters per pixel at the center latitude of the *current* map view. # This is needed to convert pixel drag distance to meters. center_lat_for_res = (map_south_lat + map_north_lat) / 2.0 center_lat_for_res = max(-85.05, min(85.05, center_lat_for_res)) # Clamp for Mercator res_m_px_at_center = calculate_meters_per_pixel(center_lat_for_res, self._current_zoom, self.tile_manager.tile_size) if res_m_px_at_center is None or res_m_px_at_center <= 1e-9: logger.warning("Could not calculate meters per pixel for pan delta. Cannot pan reliably."); self._is_dragging = False; return # Calculate the geographic distance corresponding to the pixel drag distance delta_meters_x = dx_pixel * res_m_px_at_center delta_meters_y = dy_pixel * res_m_px_at_center # Use pyproj.Geod.fwd to calculate the new geographic center after moving by delta_meters from the start center. # This is more accurate than simply multiplying degrees per pixel if the canvas covers a large area or is near poles. geod = pyproj.Geod(ellps="WGS84") # Calculate new center longitude and latitude separately. # Moving east/west by delta_meters_x at start_center_lat. # Moving north/south by delta_meters_y at start_center_lon. # Note: Need to account for direction in fwd azimuth. East is 90 deg, West is 270 or -90. North is 0, South is 180. # dx > 0 means mouse moved right (pan map left), so new center is left (delta_lon is negative). Azimuth is 270 if dx>0, 90 if dx<0. # dy > 0 means mouse moved down (pan map up), so new center is up (delta_lat is positive). Azimuth is 0 if dy>0, 180 if dy<0. # Calculate new longitude based on horizontal drag if abs(delta_meters_x) > 1e-9: azimuth_lon = 90.0 if delta_meters_x < 0 else 270.0 # If delta_x > 0, map moved right, center moves left (270). If delta_x < 0, map moved left, center moves right (90). # Clamp start_center_lat for fwd calculation start_center_lat_clamped = max(-89.99, min(89.99, self._drag_start_center_lat)) end_lon_from_drag_x, _, _ = geod.fwd(self._drag_start_center_lon, start_center_lat_clamped, azimuth_lon, abs(delta_meters_x)) new_center_lon = end_lon_from_drag_x else: new_center_lon = self._drag_start_center_lon # No horizontal movement # Calculate new latitude based on vertical drag if abs(delta_meters_y) > 1e-9: azimuth_lat = 180.0 if delta_meters_y < 0 else 0.0 # If delta_y > 0, map moved down, center moves up (0). If delta_y < 0, map moved up, center moves down (180). # Use the potentially updated longitude (new_center_lon) for vertical movement calculation, # though this dependency is minor over small distances. Let's use the start_center_lon for simplicity. start_center_lon_for_lat_drag = max(-179.99, min(179.99, self._drag_start_center_lon)) _, end_lat_from_drag_y, _ = geod.fwd(start_center_lon_for_lat_drag, self._drag_start_center_lat, azimuth_lat, abs(delta_meters_y)) new_center_lat = end_lat_from_drag_y else: new_center_lat = self._drag_start_center_lat # No vertical movement # Ensure new center is within valid Mercator latitude range MAX_MERCATOR_LATITUDE = 85.05112878 new_center_lat = max(-MAX_MERCATOR_LATITUDE, min(MAX_MERCATOR_LATITUDE, new_center_lat)) # Ensure new center longitude wraps correctly (-180 to 180) new_center_lon = (new_center_lon + 180) % 360 - 180 logger.info(f"Pan finalized. New center: ({new_center_lat:.4f}, {new_center_lon:.4f})") self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom, ensure_bbox_is_covered_dict=None) 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): if not self.canvas.winfo_exists(): return if self._current_map_geo_bounds is None or self._map_pil_image is None or self._current_zoom is None: logger.warning("Map context missing for right click geo conversion.") return map_pixel_shape = self._map_pil_image.size geo_lon_at_mouse, geo_lat_at_mouse = _pixel_to_geo(event.x, event.y, self._current_map_geo_bounds, map_pixel_shape) if geo_lon_at_mouse is not None and geo_lat_at_mouse is not None: logger.info(f"Right-click Geo: Lat={geo_lat_at_mouse:.5f}, Lon={geo_lon_at_mouse:.5f}") if self.app_controller and hasattr(self.app_controller, "on_map_right_click"): try: self.app_controller.on_map_right_click(geo_lat_at_mouse, geo_lon_at_mouse, event.x_root, event.y_root) except Exception as e: logger.error(f"Error calling controller click handler: {e}", exc_info=False) def update_flights_on_map(self, flight_states: List[CanonicalFlightState]): logger.info(f"MapCanvasManager: Update flights received {len(flight_states)}.") self._current_flights_to_display = flight_states # MODIFIED: Trigger redraw after updating flights. # WHY: To display the new flight data on the map. # HOW: Added the call. self._redraw_canvas_content() def get_current_map_info(self) -> Dict[str, Any]: map_size_km_w: Optional[float] = None; map_size_km_h: Optional[float] = None if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None and self._current_map_geo_bounds: try: size_km_tuple = calculate_geographic_bbox_size_km(self._current_map_geo_bounds) if size_km_tuple: map_size_km_w, map_size_km_h = size_km_tuple except Exception as e: logger.error(f"Error calc current map geo size: {e}", exc_info=False) info = { "center_lat": self._current_center_lat, "center_lon": self._current_center_lon, "zoom": self._current_zoom, "map_geo_bounds": self._current_map_geo_bounds, # MODIFIED: Include map geo bounds "target_bbox_input": self._target_bbox_input, # MODIFIED: Include target bbox input "canvas_width": self.canvas_width, "canvas_height": self.canvas_height, "map_size_km_w": map_size_km_w, "map_size_km_h": map_size_km_h, "flight_count": len(self._current_flights_to_display) # Include flight count } return info def show_map_context_menu( self, latitude: float, longitude: float, screen_x: int, screen_y: int ): logger.info(f"Placeholder: Show context menu for click @ Geo ({latitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f}, {longitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f})") if self.canvas.winfo_exists(): root_widget = self.canvas.winfo_toplevel() try: context_menu = tk.Menu(root_widget, tearoff=0) context_menu.add_command(label=f"Info for {latitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f},{longitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f}", state=tk.DISABLED) context_menu.add_separator() if self.app_controller and hasattr(self.app_controller, "recenter_map_at_coords"): context_menu.add_command(label="Center map here", command=lambda: self.app_controller.recenter_map_at_coords(latitude, longitude)) else: context_menu.add_command(label="Center map here (Controller missing)", state=tk.DISABLED) # MODIFIED: Add "Set as Monitoring Area" option. # WHY: Allow setting the right-clicked point as the center of a monitoring area. # HOW: Added a menu command. Needs corresponding method in controller. if self.app_controller and hasattr(self.app_controller, "set_bbox_around_coords"): # Need to define a default size for the BBox when setting from a click. # Let's use a small default area, e.g., 50x50 km. default_area_km = 50.0 context_menu.add_command(label=f"Set {default_area_km:.0f}km Area Here", command=lambda: self.app_controller.set_bbox_around_coords(latitude, longitude, default_area_km)) else: context_menu.add_command(label="Set Area Here (Controller missing)", state=tk.DISABLED) context_menu.add_command(label="Get elevation here (TBD)") try: context_menu.tk_popup(screen_x, screen_y); finally: context_menu.grab_release() except tk.TclError as e: logger.warning(f"TclError showing menu: {e}. GUI gone.") except Exception as e: logger.error(f"Error creating/showing menu: {e}", exc_info=True) def recenter_map_at_coords(self, lat: float, lon: float): logger.info(f"Request to center map @ Geo ({lat:.{map_constants.COORDINATE_DECIMAL_PLACES}f}, {lon:.{map_constants.COORDINATE_DECIMAL_PLACES}f})") if (self._current_center_lat is not None and self._current_center_lon is not None and self._current_zoom is not None and self.canvas.winfo_exists() and PIL_IMAGE_LIB_AVAILABLE and MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile is not None and PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None): self.recenter_and_redraw(lat, lon, self._current_zoom, ensure_bbox_is_covered_dict=None) # MODIFIED: Added method to set a BBox centered around specific coordinates. # WHY: To support the "Set as Monitoring Area" context menu option. # HOW: Implemented a method that calculates the BBox and calls set_target_bbox. def set_bbox_around_coords(self, center_lat: float, center_lon: float, area_size_km: float): logger.info(f"Request to set BBox around Geo ({center_lat:.4f}, {center_lon:.4f}) with size {area_size_km:.1f}km.") if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("Cannot set BBox around coords: pyproj library not available.") if self.app_controller and hasattr(self.app_controller, "show_error_message"): self.app_controller.show_error_message("Map Error", "Cannot calculate BBox: Geographic libraries missing.") return try: # Use the utility function to calculate the BBox bbox_tuple = map_utils.get_bounding_box_from_center_size(center_lat, center_lon, area_size_km) if bbox_tuple: bbox_dict = { "lon_min": bbox_tuple[0], "lat_min": bbox_tuple[1], "lon_max": bbox_tuple[2], "lat_max": bbox_tuple[3], } if _is_valid_bbox_dict(bbox_dict): logger.debug(f"Calculated BBox around coords: {bbox_dict}. Setting target BBox.") # Call set_target_bbox to update the view and draw the blue box self.set_target_bbox(bbox_dict) # Also update the GUI input fields with the new BBox values if self.app_controller and hasattr(self.app_controller, "update_bbox_gui_fields"): try: self.app_controller.update_bbox_gui_fields(bbox_dict) except Exception as e_update_gui: logger.error(f"Error updating BBox GUI fields: {e_update_gui}", exc_info=False) else: logger.error(f"Calculated BBox around coords is invalid: {bbox_dict}.") if self.app_controller and hasattr(self.app_controller, "show_error_message"): self.app_controller.show_error_message("Map Error", "Calculated BBox is invalid.") else: logger.error(f"Failed to calculate BBox around coords ({center_lat}, {center_lon}, {area_size_km}km).") if self.app_controller and hasattr(self.app_controller, "show_error_message"): self.app_controller.show_error_message("Map Error", "Failed to calculate BBox around coordinates.") except Exception as e: logger.exception(f"Unexpected error calculating BBox around coords: {e}") if self.app_controller and hasattr(self.app_controller, "show_error_message"): self.app_controller.show_error_message("Map Error", f"An unexpected error occurred calculating BBox: {e}")