1196 lines
47 KiB
Python
1196 lines
47 KiB
Python
# flightmonitor/map/map_canvas_manager.py
|
|
|
|
import tkinter as tk
|
|
import math
|
|
import time # Aggiunto per il calcolo dell'età della traccia
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
from collections import deque
|
|
|
|
try:
|
|
from PIL import Image, ImageTk, ImageDraw, ImageFont
|
|
|
|
PIL_IMAGE_LIB_AVAILABLE = True
|
|
except ImportError:
|
|
Image, ImageTk, ImageDraw, ImageFont = None, None, None, None # type: ignore
|
|
PIL_IMAGE_LIB_AVAILABLE = False
|
|
import logging
|
|
|
|
logging.error(
|
|
"MapCanvasManager: Pillow (Image, ImageTk, ImageDraw, ImageFont) not found. Map disabled."
|
|
)
|
|
|
|
try:
|
|
import pyproj
|
|
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE = True
|
|
except ImportError:
|
|
pyproj = None # type: ignore
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE = False
|
|
import logging
|
|
|
|
logging.warning(
|
|
"MapCanvasManager: 'pyproj' not found. Geographic calculations impaired."
|
|
)
|
|
|
|
try:
|
|
import mercantile
|
|
|
|
MERCANTILE_MODULE_LOCALLY_AVAILABLE = True
|
|
except ImportError:
|
|
mercantile = None # type: ignore
|
|
MERCANTILE_MODULE_LOCALLY_AVAILABLE = False
|
|
import logging
|
|
|
|
logging.error(
|
|
"MapCanvasManager: 'mercantile' not found. Tile conversions fail, map unusable."
|
|
)
|
|
|
|
|
|
from . import map_constants
|
|
from ..data import config as app_config
|
|
|
|
from ..data.common_models import CanonicalFlightState
|
|
from .map_services import BaseMapService, OpenStreetMapService
|
|
from .map_tile_manager import (
|
|
MapTileManager,
|
|
) # MODIFIED: Changed from map_manager to map_tile_manager
|
|
from .map_utils import (
|
|
get_tile_ranges_for_bbox,
|
|
calculate_geographic_bbox_size_km,
|
|
calculate_geographic_bbox_from_pixel_size_and_zoom,
|
|
deg_to_dms_string,
|
|
_is_valid_bbox_dict,
|
|
_pixel_to_geo,
|
|
calculate_meters_per_pixel,
|
|
calculate_zoom_level_for_geographic_size,
|
|
get_bounding_box_from_center_size,
|
|
)
|
|
from . import map_drawing
|
|
|
|
try:
|
|
from ..utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
except ImportError:
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning("MapCanvasManager using fallback standard Python logger.")
|
|
|
|
|
|
CANVAS_SIZE_HARD_FALLBACK_PX = getattr(app_config, "DEFAULT_CANVAS_WIDTH", 800)
|
|
# MODIFIED: Referenced map_constants for MAP_TILE_CACHE_DIR
|
|
# WHY: Ensure consistency with where MAP_TILE_CACHE_DIR is defined (expected in map_constants or app_config)
|
|
# HOW: Changed app_config to map_constants. Assuming it's defined there. If it's in app_config, this should revert.
|
|
MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr(
|
|
map_constants, "MAP_TILE_CACHE_DIR", "flightmonitor_tile_cache_fallback"
|
|
)
|
|
RESIZE_DEBOUNCE_DELAY_MS = 150
|
|
PAN_STEP_FRACTION = 0.25
|
|
DEFAULT_MAX_TRACK_POINTS = 20
|
|
DEFAULT_MAX_TRACK_AGE_SECONDS = 300
|
|
|
|
|
|
class MapCanvasManager:
|
|
|
|
def __init__(
|
|
self,
|
|
app_controller: Any,
|
|
tk_canvas: tk.Canvas,
|
|
initial_bbox_dict: Dict[str, float],
|
|
):
|
|
logger.info("Initializing MapCanvasManager...")
|
|
if (
|
|
not PIL_IMAGE_LIB_AVAILABLE
|
|
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
or mercantile is None
|
|
):
|
|
critical_msg = "MapCanvasManager critical dependencies missing: Pillow or Mercantile. Map disabled."
|
|
logger.critical(critical_msg)
|
|
if app_controller and hasattr(app_controller, "show_error_message"):
|
|
try:
|
|
app_controller.show_error_message(
|
|
"Map Initialization Error", critical_msg
|
|
)
|
|
except Exception:
|
|
pass
|
|
raise ImportError(critical_msg)
|
|
|
|
self.app_controller = app_controller
|
|
self.canvas = tk_canvas
|
|
|
|
self.canvas_width = self.canvas.winfo_width()
|
|
if self.canvas_width <= 1:
|
|
self.canvas_width = CANVAS_SIZE_HARD_FALLBACK_PX
|
|
self.canvas_height = self.canvas.winfo_height()
|
|
if self.canvas_height <= 1:
|
|
self.canvas_height = getattr(app_config, "DEFAULT_CANVAS_HEIGHT", 600)
|
|
|
|
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 for MapCanvasManager.")
|
|
|
|
self._current_center_lat: Optional[float] = None
|
|
self._current_center_lon: Optional[float] = None
|
|
self._current_zoom: int = map_constants.DEFAULT_INITIAL_ZOOM
|
|
self._current_map_geo_bounds: Optional[Tuple[float, float, float, float]] = None
|
|
|
|
self._map_pil_image: Optional[Image.Image] = None
|
|
self._map_photo_image: Optional[ImageTk.PhotoImage] = None
|
|
self._canvas_image_id: Optional[int] = None
|
|
|
|
self.map_service: BaseMapService = OpenStreetMapService()
|
|
self.tile_manager: MapTileManager = MapTileManager(
|
|
map_service=self.map_service,
|
|
cache_root_directory=MAP_TILE_CACHE_DIR_HARD_FALLBACK,
|
|
tile_pixel_size=self.map_service.tile_size,
|
|
)
|
|
logger.info(
|
|
f"MapTileManager initialized for service '{self.tile_manager.service_identifier_name}'."
|
|
)
|
|
|
|
self._target_bbox_input: Optional[Dict[str, float]] = None
|
|
self._current_flights_to_display: List[CanonicalFlightState] = []
|
|
|
|
self.flight_tracks: Dict[str, deque[Tuple[float, float, float]]] = {}
|
|
self.max_track_points: int = DEFAULT_MAX_TRACK_POINTS
|
|
self.max_track_age_seconds: float = DEFAULT_MAX_TRACK_AGE_SECONDS
|
|
|
|
self._resize_debounce_job_id: Optional[str] = None
|
|
|
|
if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict):
|
|
self._target_bbox_input = initial_bbox_dict.copy()
|
|
self.update_map_view_for_bbox(
|
|
initial_bbox_dict, preserve_current_zoom_if_possible=False
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"Invalid initial_bbox_dict: {initial_bbox_dict}. Using default fallback view."
|
|
)
|
|
default_bbox_cfg = {
|
|
"lat_min": app_config.DEFAULT_BBOX_LAT_MIN,
|
|
"lon_min": app_config.DEFAULT_BBOX_LON_MIN,
|
|
"lat_max": app_config.DEFAULT_BBOX_LAT_MAX,
|
|
"lon_max": app_config.DEFAULT_BBOX_LON_MAX,
|
|
}
|
|
if _is_valid_bbox_dict(default_bbox_cfg):
|
|
self._target_bbox_input = default_bbox_cfg.copy()
|
|
self.set_target_bbox(default_bbox_cfg)
|
|
else:
|
|
logger.critical(
|
|
f"Default fallback BBox from config is invalid: {default_bbox_cfg}. Cannot initialize map view."
|
|
)
|
|
self._target_bbox_input = None
|
|
self._current_center_lat = getattr(
|
|
app_config, "DEFAULT_MAP_CENTER_LAT", 45.0
|
|
)
|
|
self._current_center_lon = getattr(
|
|
app_config, "DEFAULT_MAP_CENTER_LON", 9.0
|
|
)
|
|
self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM
|
|
self.recenter_and_redraw(
|
|
self._current_center_lat,
|
|
self._current_center_lon,
|
|
self._current_zoom,
|
|
)
|
|
|
|
self._setup_event_bindings()
|
|
logger.info(
|
|
f"MapCanvasManager initialized for canvas size {self.canvas_width}x{self.canvas_height}."
|
|
)
|
|
|
|
def _setup_event_bindings(self):
|
|
self.canvas.bind("<Configure>", self._on_canvas_resize)
|
|
self.canvas.bind("<ButtonPress-1>", self._on_left_button_press)
|
|
self.canvas.bind("<ButtonRelease-1>", self._on_left_button_release)
|
|
self.canvas.bind("<ButtonPress-3>", self._on_right_click)
|
|
self._drag_start_x_canvas: Optional[int] = None
|
|
self._drag_start_y_canvas: Optional[int] = None
|
|
self._is_left_button_pressed: bool = False
|
|
|
|
def _on_canvas_resize(self, event: tk.Event):
|
|
new_width, new_height = event.width, event.height
|
|
if (
|
|
new_width > 1
|
|
and new_height > 1
|
|
and (self.canvas_width != new_width or self.canvas_height != new_height)
|
|
):
|
|
if self._resize_debounce_job_id:
|
|
try:
|
|
self.canvas.after_cancel(self._resize_debounce_job_id)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self._resize_debounce_job_id = None
|
|
self._resize_debounce_job_id = self.canvas.after(
|
|
RESIZE_DEBOUNCE_DELAY_MS,
|
|
self._perform_resize_redraw,
|
|
new_width,
|
|
new_height,
|
|
)
|
|
|
|
def _perform_resize_redraw(self, width: int, height: int):
|
|
self._resize_debounce_job_id = None
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
logger.info(
|
|
f"Performing debounced resize redraw for dimensions {width}x{height}."
|
|
)
|
|
self.canvas_width, self.canvas_height = width, height
|
|
if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input):
|
|
logger.debug("Refitting map view to target BBox after resize.")
|
|
self.update_map_view_for_bbox(
|
|
self._target_bbox_input, preserve_current_zoom_if_possible=True
|
|
)
|
|
elif (
|
|
self._current_center_lat is not None
|
|
and self._current_center_lon is not None
|
|
and self._current_zoom is not None
|
|
):
|
|
logger.debug("Recentering map view at current geo center after resize.")
|
|
self.recenter_and_redraw(
|
|
self._current_center_lat, self._current_center_lon, self._current_zoom
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"No valid BBox or geo center after resize. Cannot redraw effectively."
|
|
)
|
|
|
|
def set_max_track_points(self, length: int):
|
|
new_length = max(2, length)
|
|
if self.max_track_points != new_length:
|
|
logger.info(
|
|
f"MapCanvasManager: Max track points updating from {self.max_track_points} to {new_length}"
|
|
)
|
|
self.max_track_points = new_length
|
|
|
|
for icao in list(self.flight_tracks.keys()):
|
|
track_deque = self.flight_tracks.get(icao)
|
|
if track_deque:
|
|
while len(track_deque) > self.max_track_points:
|
|
track_deque.popleft()
|
|
if not track_deque:
|
|
if icao in self.flight_tracks:
|
|
del self.flight_tracks[icao]
|
|
|
|
if self.canvas.winfo_exists() and self._map_pil_image:
|
|
logger.debug("Forcing map redraw after track length change.")
|
|
self._redraw_canvas_content()
|
|
else:
|
|
logger.debug(
|
|
f"MapCanvasManager: Max track points already set to {new_length}. No change."
|
|
)
|
|
|
|
def set_target_bbox(self, new_bbox_dict: Dict[str, float]):
|
|
logger.info(f"MapCanvasManager: New target BBox requested: {new_bbox_dict}")
|
|
if new_bbox_dict and _is_valid_bbox_dict(new_bbox_dict):
|
|
self._target_bbox_input = new_bbox_dict.copy()
|
|
self.update_map_view_for_bbox(
|
|
self._target_bbox_input, preserve_current_zoom_if_possible=False
|
|
)
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "update_bbox_gui_fields"
|
|
):
|
|
self.app_controller.update_bbox_gui_fields(self._target_bbox_input)
|
|
else:
|
|
logger.warning(
|
|
f"Invalid/empty new_bbox_dict provided: {new_bbox_dict}. Clearing target BBox."
|
|
)
|
|
self._target_bbox_input = None
|
|
if (
|
|
self._current_center_lat is not None
|
|
and self._current_center_lon is not None
|
|
and self._current_zoom is not None
|
|
):
|
|
self.recenter_and_redraw(
|
|
self._current_center_lat,
|
|
self._current_center_lon,
|
|
self._current_zoom,
|
|
)
|
|
else:
|
|
self.clear_map_display()
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "update_bbox_gui_fields"
|
|
):
|
|
self.app_controller.update_bbox_gui_fields({})
|
|
|
|
def update_map_view_for_bbox(
|
|
self,
|
|
target_bbox_dict: Dict[str, float],
|
|
preserve_current_zoom_if_possible: bool = False,
|
|
):
|
|
if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict):
|
|
logger.warning(
|
|
"update_map_view_for_bbox called with invalid/no target BBox."
|
|
)
|
|
return
|
|
lat_min, lon_min, lat_max, lon_max = (
|
|
target_bbox_dict["lat_min"],
|
|
target_bbox_dict["lon_min"],
|
|
target_bbox_dict["lat_max"],
|
|
target_bbox_dict["lon_max"],
|
|
)
|
|
view_center_lat, view_center_lon = (lat_min + lat_max) / 2.0, (
|
|
lon_min + lon_max
|
|
) / 2.0
|
|
if lon_min > lon_max:
|
|
view_center_lon = (lon_min + (lon_max + 360.0)) / 2.0
|
|
if view_center_lon >= 180.0:
|
|
view_center_lon -= 360.0
|
|
|
|
zoom_to_use = self._current_zoom
|
|
if not preserve_current_zoom_if_possible or zoom_to_use is None:
|
|
patch_width_km, patch_height_km = calculate_geographic_bbox_size_km(
|
|
(lon_min, lat_min, lon_max, lat_max)
|
|
) or (None, None)
|
|
|
|
if (
|
|
patch_width_km
|
|
and patch_height_km
|
|
and self.canvas_width > 0
|
|
and self.canvas_height > 0
|
|
):
|
|
zoom_w = calculate_zoom_level_for_geographic_size(
|
|
view_center_lat,
|
|
patch_width_km * 1000,
|
|
self.canvas_width,
|
|
self.tile_manager.tile_size,
|
|
)
|
|
zoom_h = calculate_zoom_level_for_geographic_size(
|
|
view_center_lat,
|
|
patch_height_km * 1000,
|
|
self.canvas_height,
|
|
self.tile_manager.tile_size,
|
|
)
|
|
if zoom_w is not None and zoom_h is not None:
|
|
zoom_to_use = min(zoom_w, zoom_h)
|
|
elif zoom_w is not None:
|
|
zoom_to_use = zoom_w
|
|
elif zoom_h is not None:
|
|
zoom_to_use = zoom_h
|
|
else:
|
|
zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
logger.info(
|
|
f"Calculated zoom to fit BBox: {zoom_to_use} (based on W:{zoom_w}, H:{zoom_h})"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Could not calculate dimensions or canvas size for BBox zoom. Using default."
|
|
)
|
|
zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
max_zoom = (
|
|
self.map_service.max_zoom
|
|
if self.map_service
|
|
else map_constants.DEFAULT_MAX_ZOOM_FALLBACK
|
|
)
|
|
zoom_to_use = max(map_constants.MIN_ZOOM_LEVEL, min(zoom_to_use, max_zoom))
|
|
self.recenter_and_redraw(
|
|
view_center_lat,
|
|
view_center_lon,
|
|
zoom_to_use,
|
|
ensure_bbox_is_covered_dict=target_bbox_dict,
|
|
)
|
|
|
|
def recenter_and_redraw(
|
|
self,
|
|
center_lat: float,
|
|
center_lon: float,
|
|
zoom_level: int,
|
|
ensure_bbox_is_covered_dict: Optional[Dict[str, float]] = None,
|
|
):
|
|
logger.info(
|
|
f"Recentering map. Center: ({center_lat:.4f}, {center_lon:.4f}), Zoom: {zoom_level}. Ensure BBox Covered: {'Yes' if ensure_bbox_is_covered_dict else 'No'}"
|
|
)
|
|
if (
|
|
not PIL_IMAGE_LIB_AVAILABLE
|
|
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
or mercantile is None
|
|
):
|
|
logger.error("Pillow or Mercantile not available. Cannot recenter/redraw.")
|
|
self._clear_canvas_display()
|
|
return
|
|
self._current_center_lat, self._current_center_lon = center_lat, center_lon
|
|
max_zoom = (
|
|
self.map_service.max_zoom
|
|
if self.map_service
|
|
else map_constants.DEFAULT_MAX_ZOOM_FALLBACK
|
|
)
|
|
self._current_zoom = max(
|
|
map_constants.MIN_ZOOM_LEVEL, min(zoom_level, max_zoom)
|
|
)
|
|
|
|
if self.canvas_width <= 0 or self.canvas_height <= 0:
|
|
logger.error(
|
|
f"Canvas dims invalid ({self.canvas_width}x{self.canvas_height}). Cannot redraw."
|
|
)
|
|
self._clear_canvas_display()
|
|
return
|
|
|
|
canvas_geo_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom(
|
|
self._current_center_lat,
|
|
self._current_center_lon,
|
|
self.canvas_width,
|
|
self.canvas_height,
|
|
self._current_zoom,
|
|
self.tile_manager.tile_size,
|
|
)
|
|
if not canvas_geo_bbox:
|
|
logger.error(
|
|
"Failed to calculate canvas geographic BBox. Cannot fetch tiles."
|
|
)
|
|
self._clear_canvas_display()
|
|
return
|
|
|
|
fetch_bounds_for_tiles = canvas_geo_bbox
|
|
if ensure_bbox_is_covered_dict and _is_valid_bbox_dict(
|
|
ensure_bbox_is_covered_dict
|
|
):
|
|
logger.debug(
|
|
f"Ensuring BBox {ensure_bbox_is_covered_dict} is covered. Current canvas geo BBox: {canvas_geo_bbox}"
|
|
)
|
|
|
|
tile_xy_ranges = get_tile_ranges_for_bbox(
|
|
fetch_bounds_for_tiles, self._current_zoom
|
|
)
|
|
if not tile_xy_ranges:
|
|
logger.error(
|
|
f"Failed to get tile ranges for {fetch_bounds_for_tiles} at zoom {self._current_zoom}. Cannot draw."
|
|
)
|
|
self._clear_canvas_display()
|
|
if (
|
|
PIL_IMAGE_LIB_AVAILABLE
|
|
and Image is not None
|
|
and ImageDraw is not None
|
|
and self.canvas.winfo_exists()
|
|
):
|
|
try:
|
|
placeholder_img = Image.new(
|
|
"RGB",
|
|
(self.canvas_width, self.canvas_height),
|
|
map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB,
|
|
)
|
|
draw = ImageDraw.Draw(placeholder_img)
|
|
map_drawing._draw_text_on_placeholder(
|
|
draw, placeholder_img.size, "Map Error\nCannot get tiles."
|
|
)
|
|
self._map_photo_image = ImageTk.PhotoImage(placeholder_img) # type: ignore
|
|
if self.canvas.winfo_exists():
|
|
self._canvas_image_id = self.canvas.create_image(
|
|
0, 0, anchor=tk.NW, image=self._map_photo_image
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to draw tile range error placeholder: {e}",
|
|
exc_info=True,
|
|
)
|
|
return
|
|
|
|
stitched_map_pil = self.tile_manager.stitch_map_image(
|
|
self._current_zoom, tile_xy_ranges[0], tile_xy_ranges[1]
|
|
)
|
|
if not stitched_map_pil:
|
|
logger.error("Failed to stitch map image.")
|
|
self._clear_canvas_display()
|
|
return
|
|
|
|
self._current_map_geo_bounds = self.tile_manager._get_bounds_for_tile_range(
|
|
self._current_zoom, tile_xy_ranges
|
|
)
|
|
if not self._current_map_geo_bounds:
|
|
logger.warning(
|
|
"Could not determine actual stitched map geo bounds. Using fetch bounds as fallback."
|
|
)
|
|
self._current_map_geo_bounds = fetch_bounds_for_tiles
|
|
|
|
self._map_pil_image = stitched_map_pil
|
|
if self._map_pil_image and PIL_IMAGE_LIB_AVAILABLE:
|
|
self._redraw_canvas_content()
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "update_general_map_info"
|
|
):
|
|
self.app_controller.update_general_map_info()
|
|
|
|
def _redraw_canvas_content(self):
|
|
logger.debug("MapCanvasManager: _redraw_canvas_content called.")
|
|
if not PIL_IMAGE_LIB_AVAILABLE or Image is None or ImageDraw is None:
|
|
logger.warning(
|
|
"_redraw_canvas_content: Pillow/ImageDraw not available. Cannot draw."
|
|
)
|
|
self._clear_canvas_display()
|
|
return
|
|
|
|
self._clear_canvas_display()
|
|
if self._map_pil_image is None or self._current_map_geo_bounds is None:
|
|
logger.warning(
|
|
"No base map image or geo bounds. Canvas cleared, skipping overlay drawing."
|
|
)
|
|
return
|
|
|
|
if self._map_pil_image.mode != "RGBA":
|
|
base_map_rgba = self._map_pil_image.convert("RGBA")
|
|
image_to_draw_on = base_map_rgba.copy()
|
|
else:
|
|
image_to_draw_on = self._map_pil_image.copy()
|
|
|
|
img_shape = image_to_draw_on.size
|
|
draw = ImageDraw.Draw(image_to_draw_on)
|
|
|
|
# A. Disegna il BBox target (se definito)
|
|
if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input):
|
|
logger.debug(f"Drawing target BBox on map: {self._target_bbox_input}")
|
|
bbox_wesn = (
|
|
self._target_bbox_input["lon_min"],
|
|
self._target_bbox_input["lat_min"],
|
|
self._target_bbox_input["lon_max"],
|
|
self._target_bbox_input["lat_max"],
|
|
)
|
|
try:
|
|
# MODIFIED: Pass image_to_draw_on (PIL.Image.Image) instead of draw (PIL.ImageDraw.ImageDraw)
|
|
# The function map_drawing.draw_area_bounding_box expects an Image object
|
|
# and creates its own ImageDraw instance internally.
|
|
map_drawing.draw_area_bounding_box(
|
|
image_to_draw_on,
|
|
bbox_wesn,
|
|
self._current_map_geo_bounds,
|
|
img_shape,
|
|
color=map_constants.AREA_BOUNDARY_COLOR,
|
|
thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error drawing target BBox: {e}", exc_info=True)
|
|
|
|
# B. Disegna le Tracce degli Aerei
|
|
for icao, track_deque in self.flight_tracks.items():
|
|
if len(track_deque) < 2:
|
|
continue
|
|
|
|
pixel_points_for_track: List[Tuple[int, int]] = []
|
|
for lat, lon, _ts in track_deque:
|
|
pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map(
|
|
lat, lon, self._current_map_geo_bounds, img_shape
|
|
)
|
|
if pixel_coords:
|
|
pixel_points_for_track.append(pixel_coords)
|
|
|
|
if len(pixel_points_for_track) >= 2:
|
|
try:
|
|
track_color_str = "orange"
|
|
track_width = 1
|
|
draw.line(
|
|
pixel_points_for_track, fill=track_color_str, width=track_width
|
|
)
|
|
except Exception as e_track:
|
|
logger.error(
|
|
f"Error drawing track for ICAO {icao}: {e_track}",
|
|
exc_info=False,
|
|
)
|
|
|
|
# C. Disegna i Marker degli Aerei
|
|
flights_drawn_count = 0
|
|
if (
|
|
self._current_flights_to_display
|
|
and ImageDraw is not None
|
|
and map_drawing is not None
|
|
):
|
|
font_size = map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE
|
|
if self._current_zoom is not None:
|
|
font_size += self._current_zoom - map_constants.DEM_TILE_LABEL_BASE_ZOOM
|
|
label_font = map_drawing._load_label_font(font_size)
|
|
|
|
for flight in self._current_flights_to_display:
|
|
if flight.latitude is not None and flight.longitude is not None:
|
|
pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map(
|
|
flight.latitude,
|
|
flight.longitude,
|
|
self._current_map_geo_bounds,
|
|
img_shape,
|
|
)
|
|
if pixel_coords:
|
|
try:
|
|
flight_marker_color = "red"
|
|
map_drawing._draw_single_flight(
|
|
draw,
|
|
pixel_coords,
|
|
flight,
|
|
label_font,
|
|
flight_base_color_str=flight_marker_color,
|
|
)
|
|
flights_drawn_count += 1
|
|
except Exception as e_flight:
|
|
logger.error(
|
|
f"Error drawing flight {flight.icao24}: {e_flight}",
|
|
exc_info=False,
|
|
)
|
|
logger.debug(
|
|
f"Drew {flights_drawn_count} of {len(self._current_flights_to_display)} flight markers."
|
|
)
|
|
elif self._current_flights_to_display:
|
|
logger.warning(
|
|
"ImageDraw or map_drawing module not available, skipping drawing flights."
|
|
)
|
|
|
|
# D. Crea PhotoImage e aggiorna il canvas
|
|
try:
|
|
if ImageTk:
|
|
self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on)
|
|
else:
|
|
logger.error("Pillow ImageTk missing. Cannot create PhotoImage.")
|
|
self._clear_canvas_display()
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"Failed to create PhotoImage: {e}", exc_info=True)
|
|
self._clear_canvas_display()
|
|
return
|
|
|
|
if self.canvas.winfo_exists():
|
|
try:
|
|
self._canvas_image_id = self.canvas.create_image(
|
|
0, 0, anchor=tk.NW, image=self._map_photo_image
|
|
)
|
|
except tk.TclError as e:
|
|
logger.warning(
|
|
f"TclError drawing canvas image: {e}. GUI likely gone.",
|
|
exc_info=False,
|
|
)
|
|
self._canvas_image_id = None
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Unexpected error drawing canvas image: {e}", exc_info=True
|
|
)
|
|
self._canvas_image_id = None
|
|
else:
|
|
logger.debug("_redraw_canvas_content: Canvas does not exist.")
|
|
|
|
def _clear_canvas_display(self):
|
|
if self._canvas_image_id is not None and self.canvas.winfo_exists():
|
|
try:
|
|
self.canvas.delete(self._canvas_image_id)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self._canvas_image_id = None
|
|
self._map_photo_image = None
|
|
|
|
def clear_map_display(self):
|
|
logger.info(
|
|
"MapCanvasManager: Clearing all map content and resetting view state."
|
|
)
|
|
self._clear_canvas_display()
|
|
self._map_pil_image = None
|
|
self._current_flights_to_display = []
|
|
self.flight_tracks.clear()
|
|
self._current_map_geo_bounds = None
|
|
self._target_bbox_input = None
|
|
if self.canvas.winfo_exists():
|
|
try:
|
|
self.canvas.delete("placeholder_text")
|
|
except Exception:
|
|
pass
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "update_general_map_info"
|
|
):
|
|
self.app_controller.update_general_map_info()
|
|
|
|
def _on_left_button_press(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
self._drag_start_x_canvas, self._drag_start_y_canvas = event.x, event.y
|
|
self._is_left_button_pressed = True
|
|
|
|
def _on_left_button_release(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists() or not self._is_left_button_pressed:
|
|
self._is_left_button_pressed = False
|
|
return
|
|
self._is_left_button_pressed = False
|
|
if self._current_map_geo_bounds is not None and self._map_pil_image is not None:
|
|
map_pixel_shape = self._map_pil_image.size
|
|
clicked_lon, clicked_lat = _pixel_to_geo(
|
|
event.x, event.y, self._current_map_geo_bounds, map_pixel_shape
|
|
)
|
|
if clicked_lon is not None and clicked_lat is not None:
|
|
logger.debug(
|
|
f"Map Left-Clicked at Geo ({clicked_lat:.5f}, {clicked_lon:.5f}) - Canvas ({event.x},{event.y})"
|
|
)
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "on_map_left_click"
|
|
):
|
|
try:
|
|
self.app_controller.on_map_left_click(
|
|
clicked_lat, clicked_lon, event.x_root, event.y_root
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error calling controller left click handler: {e}",
|
|
exc_info=False,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"Failed to convert left click pixel ({event.x},{event.y}) to geo."
|
|
)
|
|
else:
|
|
logger.warning("Map context missing for left click geo conversion.")
|
|
self._drag_start_x_canvas, self._drag_start_y_canvas = None, None
|
|
|
|
def _on_right_click(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
if self._current_map_geo_bounds is None or self._map_pil_image is None:
|
|
logger.warning("Map context missing for right click geo conversion.")
|
|
return
|
|
map_pixel_shape = self._map_pil_image.size
|
|
geo_lon, geo_lat = _pixel_to_geo(
|
|
event.x, event.y, self._current_map_geo_bounds, map_pixel_shape
|
|
)
|
|
if geo_lon is not None and geo_lat is not None:
|
|
logger.info(f"Map Right-Clicked at Geo ({geo_lat:.5f}, {geo_lon:.5f})")
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "on_map_right_click"
|
|
):
|
|
try:
|
|
self.app_controller.on_map_right_click(
|
|
geo_lat, geo_lon, event.x_root, event.y_root
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error calling controller right click handler: {e}",
|
|
exc_info=False,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"Failed to convert right click pixel ({event.x},{event.y}) to geo."
|
|
)
|
|
|
|
def update_flights_on_map(self, flight_states: List[CanonicalFlightState]):
|
|
logger.debug(
|
|
f"MapCanvasManager: Update flights received with {len(flight_states)} states. Current max_track_points: {self.max_track_points}"
|
|
)
|
|
|
|
current_time = time.time()
|
|
active_icao_this_update = set()
|
|
|
|
for state in flight_states:
|
|
if (
|
|
state.latitude is not None
|
|
and state.longitude is not None
|
|
and state.timestamp is not None
|
|
):
|
|
active_icao_this_update.add(state.icao24)
|
|
if state.icao24 not in self.flight_tracks:
|
|
self.flight_tracks[state.icao24] = deque()
|
|
|
|
self.flight_tracks[state.icao24].append(
|
|
(state.latitude, state.longitude, state.timestamp)
|
|
)
|
|
|
|
while len(self.flight_tracks[state.icao24]) > self.max_track_points:
|
|
self.flight_tracks[state.icao24].popleft()
|
|
else:
|
|
logger.debug(
|
|
f"Skipping flight state for track update due to missing geo/ts: {state.icao24}"
|
|
)
|
|
|
|
tracks_to_remove = []
|
|
for icao, track_deque in self.flight_tracks.items():
|
|
if not track_deque:
|
|
tracks_to_remove.append(icao)
|
|
continue
|
|
|
|
last_point_time = track_deque[-1][2]
|
|
is_inactive_in_current_update = icao not in active_icao_this_update
|
|
is_track_too_old = (
|
|
current_time - last_point_time > self.max_track_age_seconds
|
|
)
|
|
|
|
if is_inactive_in_current_update and is_track_too_old:
|
|
logger.debug(
|
|
f"Removing old and inactive track for {icao}. Last seen: {current_time - last_point_time:.0f}s ago."
|
|
)
|
|
tracks_to_remove.append(icao)
|
|
elif not flight_states and is_track_too_old:
|
|
logger.debug(
|
|
f"Removing old track for {icao} (no active flights). Last seen: {current_time - last_point_time:.0f}s ago."
|
|
)
|
|
tracks_to_remove.append(icao)
|
|
|
|
for icao in tracks_to_remove:
|
|
if icao in self.flight_tracks:
|
|
del self.flight_tracks[icao]
|
|
|
|
self._current_flights_to_display = flight_states
|
|
|
|
if self.canvas.winfo_exists() and self._map_pil_image:
|
|
self._redraw_canvas_content()
|
|
elif not self._map_pil_image:
|
|
logger.debug(
|
|
"No base map image, flights/tracks will be drawn on next full redraw."
|
|
)
|
|
|
|
def get_current_map_info(self) -> Dict[str, Any]:
|
|
map_size_km_w, map_size_km_h = None, None
|
|
if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj and self._current_map_geo_bounds:
|
|
try:
|
|
size_km_tuple = calculate_geographic_bbox_size_km(
|
|
self._current_map_geo_bounds
|
|
)
|
|
if size_km_tuple:
|
|
map_size_km_w, map_size_km_h = size_km_tuple
|
|
except Exception as e:
|
|
logger.error(f"Error calc current map geo size: {e}", exc_info=False)
|
|
return {
|
|
"center_lat": self._current_center_lat,
|
|
"center_lon": self._current_center_lon,
|
|
"zoom": self._current_zoom,
|
|
"map_geo_bounds": self._current_map_geo_bounds,
|
|
"target_bbox_input": self._target_bbox_input,
|
|
"canvas_width": self.canvas_width,
|
|
"canvas_height": self.canvas_height,
|
|
"map_size_km_w": map_size_km_w,
|
|
"map_size_km_h": map_size_km_h,
|
|
"flight_count": len(self._current_flights_to_display),
|
|
}
|
|
|
|
def show_map_context_menu_from_gui(
|
|
self, latitude: float, longitude: float, screen_x: int, screen_y: int
|
|
):
|
|
logger.info(
|
|
f"MapCanvasManager: Showing context menu for click @ Geo ({latitude:.4f}, {longitude:.4f})"
|
|
)
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
root_widget = self.canvas.winfo_toplevel()
|
|
try:
|
|
context_menu = tk.Menu(root_widget, tearoff=0)
|
|
decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5)
|
|
context_menu.add_command(
|
|
label=f"Context @ {latitude:.{decimals}f},{longitude:.{decimals}f}",
|
|
state=tk.DISABLED,
|
|
)
|
|
context_menu.add_separator()
|
|
if self.app_controller:
|
|
if hasattr(self.app_controller, "recenter_map_at_coords"):
|
|
context_menu.add_command(
|
|
label="Center map here",
|
|
command=lambda: self.app_controller.recenter_map_at_coords(
|
|
latitude, longitude
|
|
),
|
|
)
|
|
if hasattr(self.app_controller, "set_bbox_around_coords"):
|
|
area_km_cfg_name = "DEFAULT_CLICK_AREA_SIZE_KM"
|
|
area_km = getattr(self.app_controller, area_km_cfg_name, 50.0)
|
|
context_menu.add_command(
|
|
label=f"Set {area_km:.0f}km Mon. Area",
|
|
command=lambda: self.app_controller.set_bbox_around_coords(
|
|
latitude, longitude, area_km
|
|
),
|
|
)
|
|
context_menu.tk_popup(screen_x, screen_y)
|
|
except tk.TclError as e:
|
|
logger.warning(f"TclError showing MapCanvasManager menu: {e}.")
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error creating/showing MapCanvasManager menu: {e}", exc_info=True
|
|
)
|
|
|
|
def recenter_map_at_coords(self, lat: float, lon: float):
|
|
logger.info(
|
|
f"MapCanvasManager: Request to recenter map @ Geo ({lat:.4f}, {lon:.4f})"
|
|
)
|
|
if (
|
|
self._current_zoom is not None
|
|
and self.canvas.winfo_exists()
|
|
and PIL_IMAGE_LIB_AVAILABLE
|
|
and MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
and mercantile is not None
|
|
):
|
|
self.recenter_and_redraw(lat, lon, self._current_zoom)
|
|
else:
|
|
logger.warning(
|
|
"Cannot recenter map: missing context (zoom, canvas, or libs)."
|
|
)
|
|
|
|
def set_bbox_around_coords(
|
|
self, center_lat: float, center_lon: float, area_size_km: float
|
|
):
|
|
logger.info(
|
|
f"MapCanvasManager: Request to set BBox around Geo ({center_lat:.4f}, {center_lon:.4f}), size {area_size_km:.1f}km."
|
|
)
|
|
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None:
|
|
logger.error("Cannot set BBox around coords: pyproj library not available.")
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "show_error_message"
|
|
):
|
|
self.app_controller.show_error_message(
|
|
"Map Error", "Cannot calculate BBox: Geographic libraries missing."
|
|
)
|
|
return
|
|
try:
|
|
bbox_tuple_wesn = get_bounding_box_from_center_size(
|
|
center_lat, center_lon, area_size_km
|
|
)
|
|
if bbox_tuple_wesn:
|
|
bbox_dict = {
|
|
"lon_min": bbox_tuple_wesn[0],
|
|
"lat_min": bbox_tuple_wesn[1],
|
|
"lon_max": bbox_tuple_wesn[2],
|
|
"lat_max": bbox_tuple_wesn[3],
|
|
}
|
|
if _is_valid_bbox_dict(bbox_dict):
|
|
self.set_target_bbox(bbox_dict)
|
|
else:
|
|
logger.error(
|
|
f"Calculated BBox around coords is invalid: {bbox_dict}."
|
|
)
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "show_error_message"
|
|
):
|
|
self.app_controller.show_error_message(
|
|
"Map Error", "Calculated BBox is invalid."
|
|
)
|
|
else:
|
|
logger.error(
|
|
f"Failed to calculate BBox around coords ({center_lat}, {center_lon}, {area_size_km}km)."
|
|
)
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "show_error_message"
|
|
):
|
|
self.app_controller.show_error_message(
|
|
"Map Error", "Failed to calculate BBox around coordinates."
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Unexpected error calculating BBox around coords: {e}")
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "show_error_message"
|
|
):
|
|
self.app_controller.show_error_message(
|
|
"Map Error", f"An unexpected error occurred calculating BBox: {e}"
|
|
)
|
|
|
|
def zoom_in_at_center(self):
|
|
if (
|
|
self._current_zoom is None
|
|
or self._current_center_lat is None
|
|
or self._current_center_lon is None
|
|
):
|
|
logger.warning(
|
|
"Cannot zoom in: current map state (zoom/center) is not defined."
|
|
)
|
|
return
|
|
if (
|
|
not self.canvas.winfo_exists()
|
|
or not PIL_IMAGE_LIB_AVAILABLE
|
|
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
):
|
|
logger.warning(
|
|
"Cannot zoom in: canvas or required libraries not available."
|
|
)
|
|
return
|
|
max_zoom = (
|
|
self.map_service.max_zoom
|
|
if self.map_service
|
|
else map_constants.DEFAULT_MAX_ZOOM_FALLBACK
|
|
)
|
|
new_zoom = min(self._current_zoom + 1, max_zoom)
|
|
if new_zoom != self._current_zoom:
|
|
logger.info(
|
|
f"Zooming in from {self._current_zoom} to {new_zoom} at current center."
|
|
)
|
|
self.recenter_and_redraw(
|
|
self._current_center_lat, self._current_center_lon, new_zoom
|
|
)
|
|
else:
|
|
logger.debug(
|
|
f"Already at max zoom ({self._current_zoom}). Cannot zoom in further."
|
|
)
|
|
|
|
def zoom_out_at_center(self):
|
|
if (
|
|
self._current_zoom is None
|
|
or self._current_center_lat is None
|
|
or self._current_center_lon is None
|
|
):
|
|
logger.warning(
|
|
"Cannot zoom out: current map state (zoom/center) is not defined."
|
|
)
|
|
return
|
|
if (
|
|
not self.canvas.winfo_exists()
|
|
or not PIL_IMAGE_LIB_AVAILABLE
|
|
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
):
|
|
logger.warning(
|
|
"Cannot zoom out: canvas or required libraries not available."
|
|
)
|
|
return
|
|
new_zoom = max(map_constants.MIN_ZOOM_LEVEL, self._current_zoom - 1)
|
|
if new_zoom != self._current_zoom:
|
|
logger.info(
|
|
f"Zooming out from {self._current_zoom} to {new_zoom} at current center."
|
|
)
|
|
self.recenter_and_redraw(
|
|
self._current_center_lat, self._current_center_lon, new_zoom
|
|
)
|
|
else:
|
|
logger.debug(
|
|
f"Already at min zoom ({self._current_zoom}). Cannot zoom out further."
|
|
)
|
|
|
|
def pan_map_fixed_step(
|
|
self, direction: str, step_fraction: float = PAN_STEP_FRACTION
|
|
):
|
|
if (
|
|
self._current_center_lat is None
|
|
or self._current_center_lon is None
|
|
or self._current_zoom is None
|
|
or self._current_map_geo_bounds is None
|
|
or self._map_pil_image is None
|
|
):
|
|
logger.warning(
|
|
"Cannot pan map: current map state or image not fully defined."
|
|
)
|
|
return
|
|
if (
|
|
not self.canvas.winfo_exists()
|
|
or not PIL_IMAGE_LIB_AVAILABLE
|
|
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
or mercantile is None
|
|
or not PYPROJ_MODULE_LOCALLY_AVAILABLE
|
|
or pyproj is None
|
|
):
|
|
logger.warning(
|
|
"Cannot pan map: canvas or required libraries not available."
|
|
)
|
|
return
|
|
|
|
delta_x_pixels, delta_y_pixels = 0, 0
|
|
pan_step_px_w = int(self.canvas_width * step_fraction)
|
|
pan_step_px_h = int(self.canvas_height * step_fraction)
|
|
|
|
if direction == "left":
|
|
delta_x_pixels = pan_step_px_w
|
|
elif direction == "right":
|
|
delta_x_pixels = -pan_step_px_w
|
|
elif direction == "up":
|
|
delta_y_pixels = pan_step_px_h
|
|
elif direction == "down":
|
|
delta_y_pixels = -pan_step_px_h
|
|
else:
|
|
logger.warning(f"Unknown pan direction: {direction}")
|
|
return
|
|
|
|
map_west, map_south, map_east, map_north = self._current_map_geo_bounds
|
|
current_view_center_lon = (map_west + map_east) / 2.0
|
|
current_view_center_lat = (map_south + map_north) / 2.0
|
|
if map_west > map_east:
|
|
current_view_center_lon = (map_west + map_east + 360) / 2.0
|
|
if current_view_center_lon > 180:
|
|
current_view_center_lon -= 360
|
|
|
|
res_m_px = calculate_meters_per_pixel(
|
|
current_view_center_lat, self._current_zoom, self.tile_manager.tile_size
|
|
)
|
|
if res_m_px is None or res_m_px <= 1e-9:
|
|
logger.error(
|
|
"Could not calculate valid resolution for panning. Cannot pan."
|
|
)
|
|
return
|
|
|
|
delta_meters_x = delta_x_pixels * res_m_px
|
|
delta_meters_y = delta_y_pixels * res_m_px
|
|
geod = pyproj.Geod(ellps="WGS84")
|
|
new_center_lon, new_center_lat = (
|
|
self._current_center_lon,
|
|
self._current_center_lat,
|
|
)
|
|
|
|
if abs(delta_meters_x) > 1e-9:
|
|
azimuth_lon = 90.0 if delta_meters_x > 0 else 270.0
|
|
clamped_start_lat = max(-89.99, min(89.99, new_center_lat))
|
|
temp_lon, _, _ = geod.fwd(
|
|
new_center_lon, clamped_start_lat, azimuth_lon, abs(delta_meters_x)
|
|
)
|
|
new_center_lon = temp_lon
|
|
if abs(delta_meters_y) > 1e-9:
|
|
azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0
|
|
clamped_start_lon = max(-179.99, min(179.99, new_center_lon))
|
|
_, temp_lat, _ = geod.fwd(
|
|
clamped_start_lon, new_center_lat, azimuth_lat, abs(delta_meters_y)
|
|
)
|
|
new_center_lat = temp_lat
|
|
|
|
MAX_MERCATOR_LAT = 85.05112878
|
|
new_center_lat = max(-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, new_center_lat))
|
|
new_center_lon = (new_center_lon + 180) % 360 - 180
|
|
logger.info(
|
|
f"Panning map '{direction}'. New target center: ({new_center_lat:.4f}, {new_center_lon:.4f})"
|
|
)
|
|
self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom)
|
|
|
|
def center_map_and_fit_patch(
|
|
self, center_lat: float, center_lon: float, patch_size_km: float
|
|
):
|
|
logger.info(
|
|
f"Request to center map at ({center_lat:.4f}, {center_lon:.4f}) and fit patch of {patch_size_km}km."
|
|
)
|
|
if (
|
|
not self.canvas.winfo_exists()
|
|
or self.canvas_width <= 0
|
|
or self.canvas_height <= 0
|
|
):
|
|
logger.error("Cannot fit patch: canvas not ready or invalid dimensions.")
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "show_error_message"
|
|
):
|
|
self.app_controller.show_error_message(
|
|
"Map Error", "Canvas not ready to fit patch."
|
|
)
|
|
return
|
|
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None:
|
|
logger.error(
|
|
"Cannot fit patch: PyProj library not available for geographic calculations."
|
|
)
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "show_error_message"
|
|
):
|
|
self.app_controller.show_error_message(
|
|
"Map Error",
|
|
"Geographic library (PyProj) missing for patch fitting.",
|
|
)
|
|
return
|
|
|
|
zoom_for_width = calculate_zoom_level_for_geographic_size(
|
|
center_lat,
|
|
patch_size_km * 1000,
|
|
self.canvas_width,
|
|
self.tile_manager.tile_size,
|
|
)
|
|
zoom_for_height = calculate_zoom_level_for_geographic_size(
|
|
center_lat,
|
|
patch_size_km * 1000,
|
|
self.canvas_height,
|
|
self.tile_manager.tile_size,
|
|
)
|
|
if zoom_for_width is None or zoom_for_height is None:
|
|
logger.error(
|
|
f"Could not calculate zoom to fit patch of {patch_size_km}km. Using current or default zoom."
|
|
)
|
|
new_zoom = (
|
|
self._current_zoom
|
|
if self._current_zoom is not None
|
|
else map_constants.DEFAULT_INITIAL_ZOOM
|
|
)
|
|
else:
|
|
new_zoom = min(zoom_for_width, zoom_for_height)
|
|
|
|
max_zoom_limit = (
|
|
self.map_service.max_zoom
|
|
if self.map_service
|
|
else map_constants.DEFAULT_MAX_ZOOM_FALLBACK
|
|
)
|
|
new_zoom = max(map_constants.MIN_ZOOM_LEVEL, min(new_zoom, max_zoom_limit))
|
|
logger.info(
|
|
f"Centering and fitting patch. Target center: ({center_lat:.4f}, {center_lon:.4f}), Calculated Zoom: {new_zoom}"
|
|
)
|
|
self.recenter_and_redraw(center_lat, center_lon, new_zoom)
|