SXXXXXXX_FlightMonitor/flightmonitor/map/map_canvas_manager.py
2025-05-27 07:33:08 +02:00

697 lines
44 KiB
Python

# flightmonitor/map/map_canvas_manager.py
import tkinter as tk
import math
from typing import Optional, Tuple, List, Dict, Any
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 # Fallback logger if app logger not yet up
logging.error("MapCanvasManager: Pillow (Image, ImageTk, ImageDraw, ImageFont) not found or incomplete. Map functionality will be severely limited or 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 will be impaired (e.g., patch fitting, accurate panning).")
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 coordinate conversions will fail, map likely unusable.")
# Assumiamo che map_constants e config siano accessibili
from . import map_constants # Per zoom, colori, etc.
from ..data import config as app_config # Per configurazioni globali come decimali coordinate
from ..data.common_models import CanonicalFlightState
from .map_services import BaseMapService, OpenStreetMapService # Esempio
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,
deg_to_dms_string,
_is_valid_bbox_dict,
_pixel_to_geo, # Per click del mouse
calculate_meters_per_pixel,
calculate_zoom_level_for_geographic_size, # Per fit patch
get_bounding_box_from_center_size # Per calcolare il BBox della patch
)
from . import map_drawing # Per disegnare sulla mappa
try:
from ..utils.logger import get_logger
logger = get_logger(__name__)
except ImportError: # Fallback se il logger dell'app non è disponibile
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) # Usa da config se possibile
MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr(app_config, "MAP_TILE_CACHE_DIR", "flightmonitor_tile_cache_fallback")
RESIZE_DEBOUNCE_DELAY_MS = 150 # Aumentato leggermente per evitare troppi redraw
PAN_STEP_FRACTION = 0.25 # Frazione della dimensione del canvas per ogni step di pan
class MapCanvasManager:
def __init__(
self,
app_controller: Any, # controller.AppController
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)
# Potrebbe essere utile notificare l'utente tramite il controller se possibile
if app_controller and hasattr(app_controller, "show_error_message"):
try:
app_controller.show_error_message("Map Initialization Error", critical_msg)
except Exception: pass # Ignora se la GUI non è pronta
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', CANVAS_SIZE_HARD_FALLBACK_PX)
if self.canvas_width <= 0 or self.canvas_height <= 0:
logger.critical(f"MapCanvasManager init with invalid canvas dims ({self.canvas_width}x{self.canvas_height}) after fallbacks.")
raise ValueError("Invalid canvas dimensions 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 # W,S,E,N
self._map_pil_image: Optional[Image.Image] = None
self._map_photo_image: Optional[ImageTk.PhotoImage] = None # type: ignore
self._canvas_image_id: Optional[int] = None
self.map_service: BaseMapService = OpenStreetMapService() # Esempio, potrebbe essere configurabile
self.tile_manager: MapTileManager = MapTileManager(
map_service=self.map_service,
cache_root_directory=MAP_TILE_CACHE_DIR_HARD_FALLBACK, # Usa costante definita sopra
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 # BBox fornito dall'utente per il monitoraggio
self._current_flights_to_display: List[CanonicalFlightState] = []
self._resize_debounce_job_id: Optional[str] = None
# Non più necessario per lo zoom con il mouse:
# self._zoom_debounce_job_id: Optional[str] = None
if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict):
self._target_bbox_input = initial_bbox_dict.copy()
self.update_map_view_for_bbox(initial_bbox_dict, preserve_current_zoom_if_possible=False)
else:
logger.warning(f"Invalid initial_bbox_dict: {initial_bbox_dict}. Using default fallback 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()
# Chiamata a set_target_bbox (che chiama update_map_view_for_bbox)
# è più appropriata per inizializzare correttamente anche la GUI BBox fields.
self.set_target_bbox(default_bbox_cfg)
else: # Fallback estremo se anche la config di default è errata
logger.critical(f"Default fallback BBox from config is invalid: {default_bbox_cfg}. Cannot initialize map view.")
self._target_bbox_input = None
# Imposta un centro e zoom di default se tutto fallisce
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() # Configura i binding degli eventi
logger.info(f"MapCanvasManager initialized for canvas size {self.canvas_width}x{self.canvas_height}.")
def _setup_event_bindings(self):
"""Configures event bindings for the map canvas."""
self.canvas.bind("<Configure>", self._on_canvas_resize)
# --- RIMOZIONE/MODIFICA BINDING MOUSE per ZOOM e PAN ---
# self.canvas.bind("<MouseWheel>", self._on_mouse_wheel_windows_macos) # ZOOM RIMOSSO
# self.canvas.bind("<Button-4>", self._on_mouse_wheel_linux) # ZOOM RIMOSSO (Linux)
# self.canvas.bind("<Button-5>", self._on_mouse_wheel_linux) # ZOOM RIMOSSO (Linux)
# Gestione Click Sinistro (solo per info, non per pan)
self.canvas.bind("<ButtonPress-1>", self._on_left_button_press)
# self.canvas.bind("<B1-Motion>", self._on_mouse_drag) # PAN RIMOSSO
self.canvas.bind("<ButtonRelease-1>", self._on_left_button_release) # Chiamerà on_map_left_click se non c'è stato drag (ora non c'è drag)
# Gestione Click Destro (per menu contestuale)
self.canvas.bind("<ButtonPress-3>", self._on_right_click) # In Windows/macOS solitamente <Button-3>, Linux <Button-3>
# Variabili per il drag (non più usate per il pan, ma potrebbero servire per altre interazioni future)
self._drag_start_x_canvas: Optional[int] = None
self._drag_start_y_canvas: Optional[int] = None
self._is_left_button_pressed: bool = False # Per distinguere click da (futuro) drag per selezione
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 # Ignora errori se il job non esiste più
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
# Decide come ridisegnare: se c'è un target BBox, riadatta a quello, altrimenti al centro corrente.
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) # Prova a mantenere lo zoom se possibile
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.")
# Potrebbe essere necessario un fallback a una vista di default se lo stato è inconsistente
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()
# Questa chiamata aggiornerà la vista e ridisegnerà la mappa e il box blu
self.update_map_view_for_bbox(self._target_bbox_input, preserve_current_zoom_if_possible=False)
# Notifica al controller di aggiornare i campi BBox nella GUI
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 # Rimuovi il box blu
# Ridisegna la mappa senza il box blu, mantenendo il centro e zoom correnti se possibile
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() # Fallback se non c'è uno stato precedente valido
if self.app_controller and hasattr(self.app_controller, "update_bbox_gui_fields"):
self.app_controller.update_bbox_gui_fields({}) # Passa dict vuoto per resettare i campi
def update_map_view_for_bbox(self, target_bbox_dict: Dict[str, float], preserve_current_zoom_if_possible: bool = False):
# ... (logica esistente per calcolare centro e zoom per il BBox) ...
# Questa funzione chiamerà recenter_and_redraw alla fine
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: # Gestione attraversamento antimeridiano per il centro
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:
# Calcola lo zoom per far entrare il BBox (logica complessa che usa pyproj, mercantile e map_utils.calculate_zoom_level_for_geographic_size)
# Per semplicità, usiamo una stima o un map_utils.calculate_zoom_for_bbox se esistesse
# Questa parte è cruciale e potrebbe richiedere una funzione helper dedicata
patch_width_km, patch_height_km = calculate_geographic_bbox_size_km(
(lon_min, lat_min, lon_max, lat_max) # W,S,E,N
) 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) # Prendi lo zoom minore per assicurare che entri tutto
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 # Fallback
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):
# ... (logica di recenter_and_redraw esistente, ma assicurati che chiami _redraw_canvas_content e update_general_map_info) ...
# Questa funzione è il cuore del ridisegno.
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
# Calcola il BBox geografico che il canvas coprirà
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 # Il BBox per cui scaricare le tile
if ensure_bbox_is_covered_dict and _is_valid_bbox_dict(ensure_bbox_is_covered_dict):
# Se dobbiamo assicurare che un BBox specifico sia coperto, le tile da scaricare devono includere quel BBox.
# Questo può essere complesso se il BBox è più grande di quanto il canvas può mostrare allo zoom corrente.
# Per ora, assumiamo che `ensure_bbox_is_covered_dict` sia usato principalmente quando si imposta la vista *per* quel BBox,
# quindi lo zoom dovrebbe già essere stato calcolato per farlo entrare.
# Potremmo unire `canvas_geo_bbox` e `ensure_bbox_is_covered_dict` per ottenere il BBox di fetch definitivo.
# Per semplicità, se `ensure_bbox_is_covered_dict` è fornito, lo usiamo come base per il fetch,
# ma questo potrebbe portare a scaricare più tile del necessario se lo zoom è alto.
# Una strategia migliore: calcola le tile per `canvas_geo_bbox`, poi calcola le tile per `ensure_bbox_is_covered_dict`
# e prendi l'unione degli intervalli di tile.
# Per ora, ci fidiamo che lo zoom sia appropriato se `ensure_bbox_is_covered_dict` è dato.
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(); # Mostra placeholder se necessario
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.") # Usa helper
self._map_photo_image = ImageTk.PhotoImage(placeholder_img) # type: ignore
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() # Questo ora include il disegno dei voli e del target_bbox
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() # Pulisci il canvas precedente
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
image_to_draw_on = self._map_pil_image.copy()
img_shape = image_to_draw_on.size
# 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:
image_to_draw_on = 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=False)
# Disegna i voli
flights_drawn_count = 0
if self._current_flights_to_display and ImageDraw is not None:
draw = ImageDraw.Draw(image_to_draw_on) # Ottieni ImageDraw una sola volta
font_size = map_constants.DEM_TILE_LABEL_BASE_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:
map_drawing._draw_single_flight(draw, pixel_coords, flight, label_font)
flights_drawn_count += 1
except Exception as e: logger.error(f"Error drawing flight {flight.icao24}: {e}", exc_info=False)
logger.debug(f"Drew {flights_drawn_count} of {len(self._current_flights_to_display)} flights.")
elif self._current_flights_to_display:
logger.warning("ImageDraw not available, skipping drawing flights.")
try:
if ImageTk: self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on) # type: ignore
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.")
# L'aggiornamento delle info generali ora è chiamato da recenter_and_redraw o metodi simili dopo il ridisegno.
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 # Ignora errori se già cancellato o canvas sparito
finally: self._canvas_image_id = None
self._map_photo_image = None # Rilascia riferimento a PhotoImage
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._current_map_geo_bounds = None
# Non resettare zoom/centro qui, potrebbero essere necessari per un placeholder sensato
# self._current_center_lat = None
# self._current_center_lon = None
# self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM
self._target_bbox_input = None # Rimuovi il BBox target
if self.canvas.winfo_exists(): # Pulisci anche eventuali placeholder di testo
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() # Aggiorna GUI con info vuote
# --- GESTIONE EVENTI MOUSE MODIFICATA ---
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
# Non impostare più il cursore a "fleur" perché non faremo pan con il drag
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
# Considera un click solo se non c'è stato movimento significativo (ora non c'è drag)
# Questa logica può essere semplificata dato che non c'è più drag per il pan.
# Un click avviene sempre al rilascio del bottone.
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})")
# Chiamata al controller che poi chiama show_map_context_menu di MainWindow
if self.app_controller and hasattr(self.app_controller, "on_map_right_click"): # o on_map_context_menu_request
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.")
self._current_flights_to_display = flight_states
if self.canvas.winfo_exists() and self._map_pil_image: # Solo se c's una mappa base su cui disegnare
self._redraw_canvas_content() # Ridisegna tutto per mostrare i nuovi voli
elif not self._map_pil_image:
logger.debug("No base map image, flights will be drawn on next full redraw.")
# Se la mappa non è attiva (es. placeholder), i voli non verranno mostrati finché non lo è.
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):
"""Mostra un menu contestuale definito da MapCanvasManager."""
# Questo è un esempio se il menu è gestito qui.
# Se il menu è gestito da MainWindow, MainWindow.show_map_context_menu sarà chiamato dal controller.
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()
# Aggiungi azioni specifiche del MapCanvasManager se necessario
# Oppure questo metodo potrebbe non essere necessario se MainWindow definisce il menu.
# Per ora, riempiamo con le stesse azioni che MainWindow avrebbe chiamato sul controller.
if self.app_controller:
if hasattr(self.app_controller, "recenter_map_at_coords"):
context_menu.add_command(label="Center map here (via Ctrl)", command=lambda: self.app_controller.recenter_map_at_coords(latitude, longitude))
if hasattr(self.app_controller, "set_bbox_around_coords"):
area_km = getattr(self.app_controller, 'DEFAULT_CLICK_AREA_SIZE_KM', 50.0)
context_menu.add_command(label=f"Set {area_km:.0f}km Area (via Ctrl)", 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):
"""Chiamato dal controller (originato da un'azione GUI come il menu contestuale)."""
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) # Mantiene lo zoom corrente
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):
"""Chiamato dal controller. Calcola BBox e aggiorna la vista."""
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"): # Notifica utente
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) # Questo aggiornerà la vista e i campi GUI tramite il controller
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}")
# --- NUOVI METODI PER CONTROLLI DA GUI ---
def zoom_in_at_center(self):
"""Zooma in avanti mantenendo il centro mappa corrente."""
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):
"""Zooma indietro mantenendo il centro mappa corrente."""
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):
"""Esegue il pan della mappa di una frazione della sua dimensione visibile."""
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: # Necessario per map_pixel_shape
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: # pyproj per calcoli geografici accurati
logger.warning("Cannot pan map: canvas or required libraries (PIL, Mercantile, PyProj) 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 # Sposta il centro a destra (mappa a sinistra)
elif direction == "right": delta_x_pixels = -pan_step_px_w # Sposta il centro a sinistra (mappa a destra)
elif direction == "up": delta_y_pixels = pan_step_px_h # Sposta il centro in basso (mappa in alto)
elif direction == "down": delta_y_pixels = -pan_step_px_h # Sposta il centro in alto (mappa in basso)
else: logger.warning(f"Unknown pan direction: {direction}"); return
# Converti delta pixel in delta geografico al centro corrente
# Questa è una conversione approssimata ma sufficiente per il pan a step fissi
# Una conversione più precisa userebbe _pixel_to_geo su due punti e calcolerebbe la differenza,
# o meglio, calcolerebbe il nuovo centro usando pyproj.Geod.fwd.
# Prendiamo il centro geografico attuale della mappa VISUALIZZATA (_current_map_geo_bounds)
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: # Antimeridian
current_view_center_lon = (map_west + map_east + 360) / 2.0
if current_view_center_lon > 180: current_view_center_lon -= 360
# Calcola metri per pixel al centro della vista corrente
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 # dy_pixel positivo sposta in basso sullo schermo -> centro mappa si sposta in alto (azimuth 0)
geod = pyproj.Geod(ellps="WGS84")
new_center_lon, new_center_lat = self._current_center_lon, self._current_center_lat # Inizia dal centro di riferimento
# Applica spostamento orizzontale
if abs(delta_meters_x) > 1e-9:
azimuth_lon = 90.0 if delta_meters_x > 0 else 270.0 # dx > 0 sposta centro a Est, dx < 0 sposta centro a Ovest
clamped_start_lat = max(-89.99, min(89.99, new_center_lat)) # Per geod.fwd
temp_lon, _, _ = geod.fwd(new_center_lon, clamped_start_lat, azimuth_lon, abs(delta_meters_x))
new_center_lon = temp_lon
# Applica spostamento verticale
if abs(delta_meters_y) > 1e-9:
azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0 # dy > 0 sposta centro a Nord, dy < 0 sposta centro a Sud
clamped_start_lon = max(-179.99, min(179.99, new_center_lon)) # Per geod.fwd
_, temp_lat, _ = geod.fwd(clamped_start_lon, new_center_lat, azimuth_lat, abs(delta_meters_y))
new_center_lat = temp_lat
# Clamp e normalizza le nuove coordinate del centro
MAX_MERCATOR_LAT = 85.05112878 # Limite per Web Mercator
new_center_lat = max(-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, new_center_lat))
new_center_lon = (new_center_lon + 180) % 360 - 180 # Normalizza lon a [-180, 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):
"""Centra la mappa e adatta lo zoom per visualizzare una patch di date dimensioni."""
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
# Calcola lo zoom necessario per far entrare la patch (assumendo una patch quadrata)
# Usa la dimensione più piccola del canvas (width o height) per determinare la dimensione geografica che deve coprire.
# La patch_size_km definisce la larghezza E l'altezza della patch geografica.
# Dobbiamo trovare uno zoom tale che la patch_size_km entri sia in larghezza che in altezza del canvas.
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:
# Scegli lo zoom PIÙ BASSO (numero più piccolo) tra i due,
# perché uno zoom più basso copre un'area geografica più grande,
# garantendo così che la patch entri in entrambe le dimensioni.
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}")
# Potremmo voler calcolare un BBox che rappresenti la patch e passarlo a ensure_bbox_is_covered_dict
# per una maggiore precisione nel ridisegno, ma per ora recentriamo e basta.
# patch_bbox_wesn = get_bounding_box_from_center_size(center_lat, center_lon, patch_size_km)
# patch_bbox_dict = None
# if patch_bbox_wesn:
# patch_bbox_dict = {"lon_min": patch_bbox_wesn[0], "lat_min": patch_bbox_wesn[1],
# "lon_max": patch_bbox_wesn[2], "lat_max": patch_bbox_wesn[3]}
self.recenter_and_redraw(center_lat, center_lon, new_zoom) # ensure_bbox_is_covered_dict=patch_bbox_dict