1886 lines
116 KiB
Python
1886 lines
116 KiB
Python
# 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("<Configure>", self._on_canvas_resize)
|
|
self.canvas.bind("<MouseWheel>", self._on_mouse_wheel_windows_macos)
|
|
self.canvas.bind("<Button-4>", self._on_mouse_wheel_linux) # Scroll up on Linux
|
|
self.canvas.bind("<Button-5>", self._on_mouse_wheel_linux) # Scroll down on Linux
|
|
self.canvas.bind("<ButtonPress-1>", self._on_mouse_button_press) # Left click press for drag start
|
|
self.canvas.bind("<B1-Motion>", self._on_mouse_drag) # Mouse motion while left button is held
|
|
self.canvas.bind("<ButtonRelease-1>", self._on_mouse_button_release) # Left click release to finalize drag
|
|
self.canvas.bind("<ButtonPress-3>", 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)
|
|
|