SXXXXXXX_FlightMonitor/flightmonitor/map/map_canvas_manager.py
2025-05-16 12:23:48 +02:00

688 lines
38 KiB
Python

# flightmonitor/map/map_canvas_manager.py
import tkinter as tk
# from tkinter import ttk # Non usato direttamente qui per ora, ma potrebbe servire in futuro
try:
from PIL import Image, ImageTk, ImageDraw, ImageFont # ImageFont per il testo
PIL_IMAGE_LIB_AVAILABLE = True
except ImportError:
Image = None # type: ignore
ImageTk = None # type: ignore
ImageDraw = None # type: ignore
ImageFont = None # type: ignore
PIL_IMAGE_LIB_AVAILABLE = False
# Il logger potrebbe non essere ancora inizializzato qui se questo file viene importato molto presto
print("ERROR: MapCanvasManager - Pillow (PIL/ImageTk/ImageDraw/ImageFont) library not found or incomplete.")
try:
import pyproj # Importa pyproj direttamente
PYPROJ_MODULE_LOCALLY_AVAILABLE = True # Flag specifico per la disponibilità di pyproj in questo modulo
except ImportError:
pyproj = None # type: ignore
PYPROJ_MODULE_LOCALLY_AVAILABLE = False
print("WARNING: MapCanvasManager - 'pyproj' library not found. Some geographic calculations will be impaired.")
try:
import mercantile # Importa mercantile direttamente
MERCANTILE_MODULE_LOCALLY_AVAILABLE = True
except ImportError:
mercantile = None # type: ignore
MERCANTILE_MODULE_LOCALLY_AVAILABLE = False
print("WARNING: MapCanvasManager - 'mercantile' library not found. Coordinate conversions will be impaired.")
import math
from typing import Optional, Tuple, List, Dict, Any
# Importazioni da FlightMonitor
from ..data.common_models import CanonicalFlightState
from ..data import config as fm_config
# Importazioni dai moduli mappa
from .map_services import BaseMapService, OpenStreetMapService
from .map_manager import MapTileManager
from .map_utils import (
get_tile_ranges_for_bbox,
calculate_geographic_bbox_size_km,
calculate_geographic_bbox_from_pixel_size_and_zoom,
# Rimuovi MERCANTILE_AVAILABLE_UTILS e PYPROJ_AVAILABLE da qui se usiamo i flag locali
# MERCANTILE_AVAILABLE_UTILS as UTILS_MERCANTILE_AVAILABLE,
# PYPROJ_AVAILABLE as UTILS_PYPROJ_AVAILABLE
)
from . import map_drawing
# Logger di FlightMonitor
try:
from ...utils.logger import get_logger
except ImportError:
try:
from ..utils.logger import get_logger
except ImportError:
import logging
get_logger = logging.getLogger
print("WARNING: MapCanvasManager using fallback basic logger for get_logger.")
logger = get_logger(__name__)
# Costanti per la mappa
DEFAULT_INITIAL_ZOOM = 7
MIN_ZOOM_LEVEL = 0
DEFAULT_MAX_ZOOM_FALLBACK = 19
FALLBACK_CANVAS_WIDTH = 800
FALLBACK_CANVAS_HEIGHT = 600
class MapCanvasManager:
def __init__(self, app_controller: Any, tk_canvas: tk.Canvas, initial_bbox_dict: Dict[str, float]):
logger.info("Initializing MapCanvasManager...")
if not (MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile is not None and \
PIL_IMAGE_LIB_AVAILABLE and Image is not None and \
ImageTk is not None and ImageDraw is not None and ImageFont is not None) :
critical_msg = "MapCanvasManager critical dependency missing: Mercantile or Pillow (Image, ImageTk, ImageDraw, ImageFont) not fully available."
logger.critical(critical_msg)
raise ImportError(critical_msg)
self.app_controller = app_controller
self.canvas = tk_canvas
self.canvas_width = self.canvas.winfo_width()
if self.canvas_width <= 1: self.canvas_width = getattr(fm_config, 'DEFAULT_CANVAS_WIDTH', FALLBACK_CANVAS_WIDTH)
self.canvas_height = self.canvas.winfo_height()
if self.canvas_height <= 1: self.canvas_height = getattr(fm_config, 'DEFAULT_CANVAS_HEIGHT', FALLBACK_CANVAS_HEIGHT)
self._current_center_lat: Optional[float] = None
self._current_center_lon: Optional[float] = None
self._current_zoom: int = DEFAULT_INITIAL_ZOOM
self._current_map_geo_bounds: Optional[Tuple[float, float, float, float]] = None
self._map_pil_image: Optional[Image.Image] = None
self._map_photo_image: Optional[ImageTk.PhotoImage] = None
self._canvas_image_id: Optional[int] = None
self.map_service: BaseMapService = OpenStreetMapService()
cache_dir = getattr(fm_config, 'MAP_TILE_CACHE_DIR', "flightmonitor_tile_cache")
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 with tile size: {self.tile_manager.tile_size}px, cache: {cache_dir}")
self._target_bbox_input: Optional[Dict[str, float]] = None
self._active_api_bbox_for_flights: Optional[Dict[str,float]] = None
self._current_flights_to_display: List[CanonicalFlightState] = []
self.set_target_bbox(initial_bbox_dict) # Chiamata iniziale per impostare e disegnare
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("<MouseWheel>", self._on_mouse_wheel_windows_macos)
self.canvas.bind("<Button-4>", self._on_mouse_wheel_linux)
self.canvas.bind("<Button-5>", self._on_mouse_wheel_linux)
self.canvas.bind("<ButtonPress-1>", self._on_mouse_button_press)
self.canvas.bind("<B1-Motion>", self._on_mouse_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_mouse_button_release)
self.canvas.bind("<ButtonPress-3>", self._on_right_click)
self._drag_start_x: Optional[int] = None
self._drag_start_y: Optional[int] = None
self._is_dragging: bool = False
logger.debug("Map canvas event bindings configured.")
def _on_canvas_resize(self, event: tk.Event):
new_width = event.width
new_height = event.height
if new_width > 1 and new_height > 1 and \
(self.canvas_width != new_width or self.canvas_height != new_height):
logger.info(f"Canvas resized from {self.canvas_width}x{self.canvas_height} to {new_width}x{new_height}")
self.canvas_width = new_width
self.canvas_height = new_height
if self._target_bbox_input:
self.update_map_view_for_bbox(self._target_bbox_input, preserve_current_zoom_if_possible=False)
elif self._current_center_lat is not None and self._current_center_lon is not None:
self.recenter_and_redraw(self._current_center_lat, self._current_center_lon, self._current_zoom)
else:
default_bbox = {
"lat_min": fm_config.DEFAULT_BBOX_LAT_MIN, "lon_min": fm_config.DEFAULT_BBOX_LON_MIN,
"lat_max": fm_config.DEFAULT_BBOX_LAT_MAX, "lon_max": fm_config.DEFAULT_BBOX_LON_MAX
}
self.update_map_view_for_bbox(default_bbox, preserve_current_zoom_if_possible=False)
def set_target_bbox(self, new_bbox_dict: Dict[str, float]):
logger.info(f"MapCanvasManager: New target BBox received: {new_bbox_dict}")
self._active_api_bbox_for_flights = new_bbox_dict.copy()
# Ricalcola lo zoom per il nuovo BBox e ridisegna
self.update_map_view_for_bbox(new_bbox_dict, preserve_current_zoom_if_possible=False)
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:
logger.warning("update_map_view_for_bbox called with no target_bbox_dict.")
return
self._target_bbox_input = target_bbox_dict.copy()
lat_min = target_bbox_dict['lat_min']
lon_min = target_bbox_dict['lon_min']
lat_max = target_bbox_dict['lat_max']
lon_max = target_bbox_dict['lon_max']
center_lat = (lat_min + lat_max) / 2.0
center_lon = (lon_min + lon_max) / 2.0
if lon_min > lon_max:
center_lon_adjusted = (lon_min + (lon_max + 360.0)) / 2.0
center_lon = center_lon_adjusted - 360.0 if center_lon_adjusted >= 180.0 else center_lon_adjusted
logger.info(f"Updating map view for target BBox: {self._target_bbox_input}. Calculated center: ({center_lat:.4f}, {center_lon:.4f})")
new_zoom_level_to_use = self._current_zoom if self._current_zoom is not None and preserve_current_zoom_if_possible else DEFAULT_INITIAL_ZOOM
if not preserve_current_zoom_if_possible:
calculated_zoom_val = DEFAULT_INITIAL_ZOOM
if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None:
try:
geod = pyproj.Geod(ellps="WGS84")
_, _, height_m = geod.inv(center_lon, lat_min, center_lon, lat_max)
_, _, width_m = geod.inv(lon_min, center_lat, lon_max, center_lat)
height_m = abs(height_m)
width_m = abs(width_m)
logger.debug(f"Geographic dimensions of target BBox: Width={width_m:.0f}m, Height={height_m:.0f}m")
current_canvas_width = self.canvas.winfo_width()
current_canvas_height = self.canvas.winfo_height()
if current_canvas_width <= 1: current_canvas_width = self.canvas_width
if current_canvas_height <= 1: current_canvas_height = self.canvas_height
res_needed_for_height = float('inf')
if current_canvas_height > 0 and height_m > 0:
res_needed_for_height = height_m / current_canvas_height
res_needed_for_width = float('inf')
if current_canvas_width > 0 and width_m > 0:
res_needed_for_width = width_m / current_canvas_width
target_resolution_m_px = float('inf')
valid_res_found = False
if res_needed_for_height != float('inf') and res_needed_for_height > 0:
target_resolution_m_px = res_needed_for_height
valid_res_found = True
# Modifica qui per usare max correttemente
if res_needed_for_width != float('inf') and res_needed_for_width > 0:
if valid_res_found:
target_resolution_m_px = max(target_resolution_m_px, res_needed_for_width)
else:
target_resolution_m_px = res_needed_for_width
valid_res_found = True
if not valid_res_found: target_resolution_m_px = float('inf')
logger.debug(f"Canvas: {current_canvas_width}x{current_canvas_height}. Resolutions needed H,W (m/px): {res_needed_for_height:.2f}, {res_needed_for_width:.2f}. Target res: {target_resolution_m_px:.2f} m/px")
if target_resolution_m_px > 0 and target_resolution_m_px != float('inf'):
EARTH_CIRCUMFERENCE_METERS = 40075016.686
clamped_center_lat_for_cos = max(-85.05, min(85.05, center_lat))
cos_val = math.cos(math.radians(clamped_center_lat_for_cos))
if cos_val > 1e-9 and self.tile_manager.tile_size > 0:
term_for_log = (EARTH_CIRCUMFERENCE_METERS * cos_val) / \
(self.tile_manager.tile_size * target_resolution_m_px)
if term_for_log > 0:
precise_zoom = math.log2(term_for_log)
calculated_zoom_val = int(round(precise_zoom))
max_zoom_limit = self.map_service.max_zoom if self.map_service else DEFAULT_MAX_ZOOM_FALLBACK
new_zoom_level_to_use = max(MIN_ZOOM_LEVEL, min(calculated_zoom_val, max_zoom_limit))
logger.info(f"Calculated zoom {new_zoom_level_to_use} to fit BBox (precise float: {precise_zoom:.2f}).")
else:
logger.warning(f"Cannot calculate zoom for BBox: term for log2 is non-positive ({term_for_log}). Using zoom {DEFAULT_INITIAL_ZOOM}.")
new_zoom_level_to_use = DEFAULT_INITIAL_ZOOM
else:
logger.warning(f"Cannot calculate zoom for BBox: cosine of latitude or tile_size is problematic. Using zoom {DEFAULT_INITIAL_ZOOM}.")
new_zoom_level_to_use = DEFAULT_INITIAL_ZOOM
else:
logger.warning(f"Cannot calculate zoom for BBox: target resolution is invalid or zero. Using zoom {DEFAULT_INITIAL_ZOOM}.")
new_zoom_level_to_use = DEFAULT_INITIAL_ZOOM
except Exception as e:
logger.error(f"Error calculating zoom for BBox using pyproj: {e}. Using zoom {DEFAULT_INITIAL_ZOOM}.", exc_info=True)
new_zoom_level_to_use = DEFAULT_INITIAL_ZOOM
else:
logger.warning("Pyproj module not available in MapCanvasManager, cannot accurately calculate zoom for BBox. Using default zoom.")
new_zoom_level_to_use = DEFAULT_INITIAL_ZOOM
self.recenter_and_redraw(center_lat, center_lon, new_zoom_level_to_use, ensure_bbox_is_covered=self._target_bbox_input)
def recenter_and_redraw(self, center_lat: float, center_lon: float, zoom_level: int,
ensure_bbox_is_covered: Optional[Dict[str,float]] = None):
logger.info(f"Recentering map. Target Center: ({center_lat:.4f}, {center_lon:.4f}), Zoom: {zoom_level}")
if ensure_bbox_is_covered:
logger.debug(f"This redraw will ensure BBox is covered: {ensure_bbox_is_covered}")
self._current_center_lat = center_lat
self._current_center_lon = center_lon
self._current_zoom = max(MIN_ZOOM_LEVEL, min(zoom_level, self.map_service.max_zoom if self.map_service else DEFAULT_MAX_ZOOM_FALLBACK))
current_canvas_width = self.canvas.winfo_width()
current_canvas_height = self.canvas.winfo_height()
if current_canvas_width <=1 : current_canvas_width = self.canvas_width
if current_canvas_height <=1 : current_canvas_height = self.canvas_height
map_fetch_geo_bounds_for_tiles: Optional[Tuple[float, float, float, float]]
if ensure_bbox_is_covered:
# Logica per calcolare un BBox di tile che copra `ensure_bbox_is_covered`
# E che sia anche centrato su `center_lat`, `center_lon` e riempia il canvas
# Questo è il caso più complesso: vogliamo vedere un BBox specifico E riempire il canvas.
# Lo zoom è già stato calcolato in `update_map_view_for_bbox` per far entrare `ensure_bbox_is_covered`.
# Quindi, i `map_fetch_geo_bounds_for_tiles` dovrebbero essere quelli che, a questo zoom e centro,
# riempiono il canvas. Se questi bounds sono più piccoli di `ensure_bbox_is_covered`,
# allora lo zoom calcolato non era sufficientemente basso.
# Per ora, semplifichiamo: il BBox per le tile sarà quello che riempie il canvas
# al centro/zoom calcolati (che dovrebbero già tenere conto di far entrare `ensure_bbox_is_covered`).
map_fetch_geo_bounds_for_tiles = calculate_geographic_bbox_from_pixel_size_and_zoom(
self._current_center_lat, self._current_center_lon,
current_canvas_width, current_canvas_height,
self._current_zoom, self.tile_manager.tile_size
)
if map_fetch_geo_bounds_for_tiles:
logger.debug(f"Tile fetching for BBox coverage, calculated canvas fill bounds: {map_fetch_geo_bounds_for_tiles} at zoom {self._current_zoom}")
else:
logger.error("Failed to calculate canvas filling BBox even when ensure_bbox_is_covered was set.")
# Fallback: usa direttamente ensure_bbox_is_covered per le tile, potrebbe non riempire il canvas
map_fetch_geo_bounds_for_tiles = (
ensure_bbox_is_covered['lon_min'], ensure_bbox_is_covered['lat_min'],
ensure_bbox_is_covered['lon_max'], ensure_bbox_is_covered['lat_max']
)
logger.warning(f"Falling back to fetching tiles strictly for ensure_bbox_is_covered: {map_fetch_geo_bounds_for_tiles}")
else: # Pan/Zoom interattivo
map_fetch_geo_bounds_for_tiles = calculate_geographic_bbox_from_pixel_size_and_zoom(
self._current_center_lat, self._current_center_lon,
current_canvas_width, current_canvas_height,
self._current_zoom, self.tile_manager.tile_size
)
if map_fetch_geo_bounds_for_tiles:
logger.debug(f"Tile fetching for interactive pan/zoom (canvas-fill): {map_fetch_geo_bounds_for_tiles} at zoom {self._current_zoom}")
if not map_fetch_geo_bounds_for_tiles:
logger.error("Failed to determine geographic bounds for tile fetching. Cannot draw map.")
self._clear_canvas_content()
return
tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bounds_for_tiles, self._current_zoom)
if not tile_xy_ranges:
logger.error(f"Failed to get tile ranges for fetch_bounds {map_fetch_geo_bounds_for_tiles}. Cannot draw map.")
self._clear_canvas_content()
return
logger.debug(f"Tile ranges for current view: X={tile_xy_ranges[0]}, Y={tile_xy_ranges[1]}")
stitched_map_pil = self.tile_manager.stitch_map_image(
self._current_zoom, tile_xy_ranges[0], tile_xy_ranges[1]
)
if not stitched_map_pil:
logger.error("Failed to stitch map image.")
self._clear_canvas_content()
return
actual_stitched_geo_bounds = self.tile_manager._get_bounds_for_tile_range(self._current_zoom, tile_xy_ranges)
if not actual_stitched_geo_bounds:
logger.error("Failed to get actual geographic bounds of stitched tiles. Using calculated fetch_bounds as fallback.")
self._current_map_geo_bounds = map_fetch_geo_bounds_for_tiles
else:
self._current_map_geo_bounds = actual_stitched_geo_bounds
logger.debug(f"Actual geographic bounds of final stitched map: {self._current_map_geo_bounds}")
self._map_pil_image = stitched_map_pil
self._redraw_canvas_content()
def _redraw_canvas_content(self):
logger.debug(f"_redraw_canvas_content called. Current zoom: {self._current_zoom}, Flights to draw: {len(self._current_flights_to_display)}")
if self.canvas.winfo_exists():
try:
self.canvas.delete("placeholder_text")
logger.debug("MapCanvasManager: Cleared 'placeholder_text' from canvas.")
except tk.TclError: pass
if self._map_pil_image is None or self._current_map_geo_bounds is None:
logger.warning("No base map image or geo bounds to draw on in _redraw_canvas_content.")
self._clear_canvas_display()
return
logger.debug(f"Base map PIL image size: {self._map_pil_image.size}")
logger.debug(f"Current map geo bounds for drawing overlays: {self._current_map_geo_bounds}")
image_to_draw_on = self._map_pil_image.copy()
if self._target_bbox_input:
user_bbox_to_draw_wesn = (
self._target_bbox_input['lon_min'], self._target_bbox_input['lat_min'],
self._target_bbox_input['lon_max'], self._target_bbox_input['lat_max']
)
logger.debug(f"Attempting to draw user target BBox (blue): {user_bbox_to_draw_wesn} onto map with bounds {self._current_map_geo_bounds}")
if map_drawing and hasattr(map_drawing, 'draw_area_bounding_box'):
image_to_draw_on = map_drawing.draw_area_bounding_box(
pil_image_to_draw_on=image_to_draw_on,
area_geo_bbox=user_bbox_to_draw_wesn,
current_map_geo_bounds=self._current_map_geo_bounds,
current_stitched_map_pixel_shape=(image_to_draw_on.height, image_to_draw_on.width),
color="blue", thickness=2
)
logger.debug(f"Drew user target BBox (blue): {user_bbox_to_draw_wesn}")
else:
logger.warning("map_drawing.draw_area_bounding_box not available.")
flights_drawn_count = 0
if self._current_flights_to_display:
logger.info(f"Attempting to draw {len(self._current_flights_to_display)} flights.")
for flight in self._current_flights_to_display:
if isinstance(flight, CanonicalFlightState) and \
flight.latitude is not None and flight.longitude is not None:
is_in_api_bbox_str = "N/A" # Default se _active_api_bbox_for_flights è None
if self._active_api_bbox_for_flights:
api_bb = self._active_api_bbox_for_flights
is_in_api_bbox = (api_bb['lon_min'] <= flight.longitude <= api_bb['lon_max'] and
api_bb['lat_min'] <= flight.latitude <= api_bb['lat_max'])
is_in_api_bbox_str = str(is_in_api_bbox)
logger.debug(f"Processing flight {flight.icao24} at Geo ({flight.latitude:.4f}, {flight.longitude:.4f}). In API BBox: {is_in_api_bbox_str}")
pixel_coords = None
if map_drawing and hasattr(map_drawing, '_geo_to_pixel_on_unscaled_map'):
pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map(
flight.latitude, flight.longitude,
self._current_map_geo_bounds,
(image_to_draw_on.height, image_to_draw_on.width)
)
else:
logger.warning("_geo_to_pixel_on_unscaled_map not available from map_drawing.")
if pixel_coords:
px, py = pixel_coords
logger.debug(f"Flight {flight.icao24} Geo ({flight.latitude:.4f}, {flight.longitude:.4f}) -> Pixel ({px}, {py}) on map of size {image_to_draw_on.size}")
if not (0 <= px < image_to_draw_on.width and 0 <= py < image_to_draw_on.height):
logger.warning(f"Flight {flight.icao24} at pixel ({px}, {py}) is OUTSIDE map image bounds ({image_to_draw_on.width}x{image_to_draw_on.height}). Geo: ({flight.latitude:.4f}, {flight.longitude:.4f}). MapBounds: {self._current_map_geo_bounds}. Skipping draw.")
continue
draw = ImageDraw.Draw(image_to_draw_on)
radius = 5
bbox_ellipse = (px - radius, py - radius, px + radius, py + radius)
draw.ellipse(bbox_ellipse, outline="black", fill="red")
label = flight.callsign if flight.callsign and flight.callsign.strip() else flight.icao24
try:
font_obj = ImageFont.load_default()
text_width: int
text_height: int
if hasattr(draw, 'textbbox'):
try:
tb = draw.textbbox((0,0), label, font=font_obj) # Calc at 0,0
text_width = tb[2] - tb[0]
text_height = tb[3] - tb[1]
except TypeError:
legacy_size = draw.textsize(label, font=font_obj) # type: ignore
text_width, text_height = legacy_size[0], legacy_size[1]
except Exception:
legacy_size = draw.textsize(label, font=font_obj) # type: ignore
text_width, text_height = legacy_size[0], legacy_size[1]
else:
legacy_size = draw.textsize(label, font=font_obj) # type: ignore
text_width, text_height = legacy_size[0], legacy_size[1]
if text_width > 0 and text_height > 0:
text_anchor_x = px + radius + 3
text_anchor_y = py - text_height // 2 - radius // 2
bg_padding = 2
bg_x0 = text_anchor_x - bg_padding
bg_y0 = text_anchor_y - bg_padding
bg_x1 = text_anchor_x + text_width + bg_padding
bg_y1 = text_anchor_y + text_height + bg_padding
if bg_x0 < bg_x1 and bg_y0 < bg_y1:
draw.rectangle([bg_x0, bg_y0, bg_x1, bg_y1], fill="rgba(255, 255, 255, 200)")
draw.text((text_anchor_x, text_anchor_y), label, fill="black", font=font_obj)
else:
logger.warning(f"Zero text size for label '{label}', skipping text.")
except Exception as e_text_draw:
logger.error(f"Error drawing text label for flight {flight.icao24}: {e_text_draw}", exc_info=False)
flights_drawn_count += 1
else:
logger.warning(f"Could not convert geo to pixel for flight {flight.icao24} (Lat:{flight.latitude}, Lon:{flight.longitude}). Skipping draw.")
else:
logger.warning(f"Skipping invalid flight data or missing coordinates: {flight}")
if flights_drawn_count > 0:
logger.info(f"Successfully drew {flights_drawn_count} of {len(self._current_flights_to_display)} flights on map image.")
elif self._current_flights_to_display:
logger.warning("Flights were provided to redraw, but none were actually drawn (check coordinates/bounds/conversion).")
else:
logger.debug("No flight_states currently stored to draw in _redraw_canvas_content.")
try:
self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on)
except Exception as e:
logger.error(f"Failed to create PhotoImage in _redraw: {e}", exc_info=True)
self._clear_canvas_display()
return
self._clear_canvas_display()
if self.canvas.winfo_exists():
self._canvas_image_id = self.canvas.create_image(0, 0, anchor=tk.NW, image=self._map_photo_image)
logger.debug("Canvas redrawn by _redraw_canvas_content with latest image.")
else:
logger.warning("Canvas does not exist, cannot draw map image.")
def _clear_canvas_display(self):
if self._canvas_image_id and self.canvas.winfo_exists():
try: self.canvas.delete(self._canvas_image_id)
except tk.TclError: logger.warning("TclError deleting canvas image item.")
self._canvas_image_id = None
logger.debug("MapCanvasManager: Main map image item cleared from canvas for redraw.")
def _clear_canvas_content(self):
self._clear_canvas_display()
self._map_pil_image = None
logger.info("MapCanvasManager: All map content (PIL image) cleared.")
def _on_mouse_wheel_windows_macos(self, event: tk.Event):
self._handle_zoom(event.delta, event.x, event.y)
def _on_mouse_wheel_linux(self, event: tk.Event):
if event.num == 4: self._handle_zoom(120, event.x, event.y)
elif event.num == 5: self._handle_zoom(-120, event.x, event.y)
def _handle_zoom(self, delta: int, canvas_x: int, canvas_y: int):
if self._current_zoom is None or not self.canvas.winfo_exists():
logger.warning("_handle_zoom: Current zoom is None or canvas does not exist.")
return
geo_lon_at_mouse, geo_lat_at_mouse = self._pixel_to_geo(canvas_x, canvas_y)
target_center_lat: Optional[float] = self._current_center_lat
target_center_lon: Optional[float] = self._current_center_lon
if geo_lon_at_mouse is not None and geo_lat_at_mouse is not None:
target_center_lat, target_center_lon = geo_lat_at_mouse, geo_lon_at_mouse
logger.debug(f"Zooming around mouse geo: ({target_center_lat:.4f}, {target_center_lon:.4f})")
elif target_center_lat is None or target_center_lon is None:
logger.warning("_handle_zoom: Cannot determine zoom center. Aborting zoom.")
return
else:
logger.debug(f"Zooming around current map center: ({target_center_lat:.4f}, {target_center_lon:.4f})")
new_zoom = self._current_zoom + 1 if delta > 0 else self._current_zoom - 1
max_zoom_limit = self.map_service.max_zoom if self.map_service else DEFAULT_MAX_ZOOM_FALLBACK
new_zoom = max(MIN_ZOOM_LEVEL, min(new_zoom, max_zoom_limit))
if new_zoom != self._current_zoom:
logger.info(f"Zoom changed from {self._current_zoom} to {new_zoom}")
# Durante lo zoom interattivo, non c'è un "ensure_bbox_is_covered" fisso.
# Il _target_bbox_input (contorno blu) rimane, ma la vista si adatta.
self.recenter_and_redraw(target_center_lat, target_center_lon, new_zoom, ensure_bbox_is_covered=None)
else:
logger.debug(f"Zoom unchanged (already at min/max: {self._current_zoom})")
def _on_mouse_button_press(self, event: tk.Event):
if not self.canvas.winfo_exists(): return
self._drag_start_x = event.x
self._drag_start_y = event.y
self._is_dragging = True
self.canvas.config(cursor="fleur")
logger.debug(f"Mouse button press at ({event.x}, {event.y}) for panning.")
def _on_mouse_drag(self, event: tk.Event):
if not self._is_dragging or self._drag_start_x is None or self._drag_start_y is None or \
self._current_map_geo_bounds is None or not self.canvas.winfo_exists() or \
self._current_center_lat is None or self._current_center_lon is None or self._current_zoom is None:
return
dx_pixel = event.x - self._drag_start_x
dy_pixel = event.y - self._drag_start_y
map_width_deg = self._current_map_geo_bounds[2] - self._current_map_geo_bounds[0]
map_height_deg = self._current_map_geo_bounds[3] - self._current_map_geo_bounds[1]
if map_width_deg < 0: map_width_deg += 360
current_canvas_width = self.canvas.winfo_width()
current_canvas_height = self.canvas.winfo_height()
if current_canvas_width <=1 : current_canvas_width = self.canvas_width
if current_canvas_height <=1 : current_canvas_height = self.canvas_height
if current_canvas_width <= 0 or current_canvas_height <= 0:
logger.warning("Cannot pan, canvas dimensions are zero.")
return
deg_per_pixel_lon = map_width_deg / current_canvas_width
deg_per_pixel_lat = map_height_deg / current_canvas_height
delta_lon = -dx_pixel * deg_per_pixel_lon
delta_lat = dy_pixel * deg_per_pixel_lat
new_center_lon = self._current_center_lon + delta_lon
new_center_lat = self._current_center_lat + delta_lat
new_center_lon = (new_center_lon + 180) % 360 - 180
new_center_lat = max(-85.05112878, min(85.05112878, new_center_lat)) # Limiti Web Mercator
self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom, ensure_bbox_is_covered=None)
self._drag_start_x = event.x
self._drag_start_y = event.y
def _on_mouse_button_release(self, event: tk.Event):
if not self.canvas.winfo_exists(): return
self._is_dragging = False
self._drag_start_x = None
self._drag_start_y = None
self.canvas.config(cursor="")
logger.debug("Mouse button release, panning finished.")
def _on_right_click(self, event: tk.Event):
if not self.canvas.winfo_exists(): return
logger.info(f"Right-click at canvas pixel ({event.x}, {event.y})")
geo_lon, geo_lat = self._pixel_to_geo(event.x, event.y)
if geo_lon is not None and geo_lat is not None:
logger.info(f"Right-click corresponds to Geo: Lat={geo_lat:.5f}, Lon={geo_lon:.5f}")
if self.app_controller and hasattr(self.app_controller, 'on_map_right_click'):
self.app_controller.on_map_right_click(geo_lat, geo_lon, event.x_root, event.y_root)
else:
logger.warning("Could not convert right-click pixel to geo.")
def _pixel_to_geo(self, canvas_x: int, canvas_y: int) -> Tuple[Optional[float], Optional[float]]:
if self._current_map_geo_bounds is None or self._current_zoom is None or \
not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
logger.warning("_pixel_to_geo: Map context not ready or mercantile (local) missing.")
return None, None
current_canvas_width = self.canvas.winfo_width()
current_canvas_height = self.canvas.winfo_height()
if current_canvas_width <=1 : current_canvas_width = self.canvas_width
if current_canvas_height <=1 : current_canvas_height = self.canvas_height
if current_canvas_width <= 0 or current_canvas_height <= 0:
logger.warning("_pixel_to_geo: Canvas dimensions are zero or invalid.")
return None, None
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds
try:
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
if total_map_width_merc < 1e-9 or total_map_height_merc < 1e-9:
logger.warning(f"_pixel_to_geo: Mercator dimensions of map are zero or near-zero ({total_map_width_merc}, {total_map_height_merc}).")
return None, None
relative_x_on_canvas = canvas_x / current_canvas_width
relative_y_on_canvas = canvas_y / current_canvas_height
clicked_merc_x = map_ul_merc_x + (relative_x_on_canvas * total_map_width_merc)
clicked_merc_y = map_ul_merc_y - (relative_y_on_canvas * total_map_height_merc)
clicked_lon, clicked_lat = mercantile.lnglat(clicked_merc_x, clicked_merc_y) # type: ignore
return clicked_lon, clicked_lat
except Exception as e:
logger.error(f"Error in _pixel_to_geo conversion: {e}", exc_info=False)
return None, None
def _geo_to_pixel(self, longitude: float, latitude: float) -> Tuple[Optional[int], Optional[int]]:
if self._current_map_geo_bounds is None or self._current_zoom is None or \
not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
logger.warning("_geo_to_pixel: Map context not ready or mercantile (local) missing.")
return None, None
current_canvas_width = self.canvas.winfo_width()
current_canvas_height = self.canvas.winfo_height()
if current_canvas_width <=1 : current_canvas_width = self.canvas_width
if current_canvas_height <=1 : current_canvas_height = self.canvas_height
if current_canvas_width <= 0 or current_canvas_height <= 0:
logger.warning("_geo_to_pixel: Canvas dimensions are zero or invalid.")
return None, None
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds
try:
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
if total_map_width_merc < 1e-9 or total_map_height_merc < 1e-9:
logger.warning(f"_geo_to_pixel: Mercator dimensions are zero or near-zero.")
return None, None
target_merc_x, target_merc_y = mercantile.xy(longitude, latitude) # type: ignore
relative_x = (target_merc_x - map_ul_merc_x) / total_map_width_merc
relative_y = (map_ul_merc_y - target_merc_y) / total_map_height_merc
canvas_x = int(round(relative_x * current_canvas_width))
canvas_y = int(round(relative_y * current_canvas_height))
return canvas_x, canvas_y
except Exception as e:
logger.error(f"Error in _geo_to_pixel: {e}", exc_info=False)
return None, None
def update_flights_on_map(self, flight_states: List[CanonicalFlightState]):
logger.info(f"MapCanvasManager: update_flights_on_map received {len(flight_states)} flights.")
self._current_flights_to_display = flight_states
self._redraw_canvas_content()
def get_current_map_info(self) -> Dict[str, Any]:
info = {
"center_lat": self._current_center_lat, "center_lon": self._current_center_lon,
"zoom": self._current_zoom, "geo_bounds": self._current_map_geo_bounds,
"canvas_width": self.canvas_width, "canvas_height": self.canvas_height,
"map_size_km_w": None, "map_size_km_h": None
}
# UTILS_PYPROJ_AVAILABLE è importato da map_utils, ma noi usiamo PYPROJ_MODULE_LOCALLY_AVAILABLE
if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None and self._current_map_geo_bounds:
size_km_tuple = calculate_geographic_bbox_size_km(self._current_map_geo_bounds)
if size_km_tuple:
info["map_size_km_w"], info["map_size_km_h"] = size_km_tuple
return info