764 lines
43 KiB
Python
764 lines
43 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):
|
|
# Questa funzione ora calcola il centro e lo zoom OTTIMALI
|
|
# per visualizzare target_bbox_dict e poi chiama recenter_and_redraw.
|
|
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() # Memorizza il BBox utente
|
|
|
|
# Se questa chiamata deriva da un'impostazione esplicita del BBox (non da pan/zoom interattivo),
|
|
# aggiorna anche il BBox di riferimento per i voli.
|
|
if not preserve_current_zoom_if_possible:
|
|
self._active_api_bbox_for_flights = 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']
|
|
|
|
# Il centro della mappa sarà il centro del BBox utente
|
|
view_center_lat = (lat_min + lat_max) / 2.0
|
|
view_center_lon = (lon_min + lon_max) / 2.0
|
|
if lon_min > lon_max: # Antimeridian
|
|
center_lon_adjusted = (lon_min + (lon_max + 360.0)) / 2.0
|
|
view_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}. Target view center: ({view_center_lat:.4f}, {view_center_lon:.4f})")
|
|
|
|
# Lo zoom da usare. Se preserviamo, usiamo il corrente, altrimenti calcoliamo.
|
|
zoom_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_for_target_bbox = DEFAULT_INITIAL_ZOOM
|
|
if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None:
|
|
try:
|
|
geod = pyproj.Geod(ellps="WGS84")
|
|
_, _, height_m = geod.inv(view_center_lon, lat_min, view_center_lon, lat_max)
|
|
_, _, width_m = geod.inv(lon_min, view_center_lat, lon_max, view_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
|
|
if res_needed_for_width != float('inf') and res_needed_for_width > 0:
|
|
current_target_res = target_resolution_m_px if valid_res_found else float('inf') # Inizializza con l'altezza se valida
|
|
target_resolution_m_px = max(current_target_res, res_needed_for_width) # Prendi la risoluzione maggiore (zoom minore)
|
|
valid_res_found = True
|
|
|
|
if not valid_res_found and target_resolution_m_px == float('inf'): # Se nessuna dimensione valida
|
|
logger.warning("Could not determine valid target resolution from BBox dimensions or canvas size.")
|
|
target_resolution_m_px = 0 # Per forzare fallback
|
|
|
|
logger.debug(f"Canvas: {current_canvas_width}x{current_canvas_height}. Resolutions needed for target BBox (H,W) (m/px): {res_needed_for_height:.2f}, {res_needed_for_width:.2f}. Final 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, view_center_lat)) # Usa il centro del BBox per il calcolo dello zoom
|
|
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_for_target_bbox = int(round(precise_zoom))
|
|
max_zoom_limit = self.map_service.max_zoom if self.map_service else DEFAULT_MAX_ZOOM_FALLBACK
|
|
zoom_to_use = max(MIN_ZOOM_LEVEL, min(calculated_zoom_for_target_bbox, max_zoom_limit))
|
|
logger.info(f"Calculated zoom {zoom_to_use} to fit target BBox (precise float: {precise_zoom:.2f}).")
|
|
else:
|
|
logger.warning(f"Cannot calculate zoom for BBox: term for log2 non-positive. Using zoom {DEFAULT_INITIAL_ZOOM}.")
|
|
zoom_to_use = DEFAULT_INITIAL_ZOOM
|
|
else:
|
|
logger.warning(f"Cannot calculate zoom for BBox: cosine of latitude or tile_size problematic. Using zoom {DEFAULT_INITIAL_ZOOM}.")
|
|
zoom_to_use = DEFAULT_INITIAL_ZOOM
|
|
else:
|
|
logger.warning(f"Cannot calculate zoom for BBox: target resolution invalid. Using zoom {DEFAULT_INITIAL_ZOOM}.")
|
|
zoom_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)
|
|
zoom_to_use = DEFAULT_INITIAL_ZOOM
|
|
else:
|
|
logger.warning("Pyproj module not available. Using default zoom.")
|
|
zoom_to_use = DEFAULT_INITIAL_ZOOM
|
|
|
|
# recenter_and_redraw userà view_center_lat, view_center_lon e zoom_to_use.
|
|
# Non passerà più `ensure_bbox_is_covered` perché il centro e lo zoom
|
|
# sono già stati calcolati per mostrare il _target_bbox_input.
|
|
self.recenter_and_redraw(view_center_lat, view_center_lon, zoom_to_use)
|
|
|
|
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. Target View Center: ({center_lat:.4f}, {center_lon:.4f}), Zoom: {zoom_level}")
|
|
if ensure_bbox_is_covered_dict:
|
|
logger.debug(f"This redraw must ensure BBox is fully visible: {ensure_bbox_is_covered_dict}")
|
|
|
|
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_tuple: Optional[Tuple[float, float, float, float]]
|
|
|
|
if ensure_bbox_is_covered_dict:
|
|
# Caso 1: Dobbiamo visualizzare un BBox specifico (_target_bbox_input)
|
|
# Lo zoom e il centro sono già stati calcolati da update_map_view_for_bbox
|
|
# per far entrare questo BBox nel canvas.
|
|
# Ora dobbiamo determinare le tile da scaricare.
|
|
# Il BBox delle tile deve coprire almeno ensure_bbox_is_covered_dict.
|
|
# E, per evitare barre grigie, dovrebbe anche cercare di riempire il canvas
|
|
# mantenendo il centro e lo zoom calcolati.
|
|
|
|
user_bb = ensure_bbox_is_covered_dict
|
|
|
|
# Calcola il BBox che riempirebbe il canvas a questo centro e zoom
|
|
canvas_fill_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom(
|
|
self._current_center_lat, self._current_center_lon, # Centro del BBox utente
|
|
current_canvas_width, current_canvas_height,
|
|
self._current_zoom, self.tile_manager.tile_size
|
|
)
|
|
|
|
if not canvas_fill_bbox:
|
|
logger.error("Failed to calculate canvas_fill_bbox even when ensure_bbox_is_covered was set. Using user_bbox directly.")
|
|
map_fetch_geo_bounds_for_tiles_tuple = (
|
|
user_bb['lon_min'], user_bb['lat_min'],
|
|
user_bb['lon_max'], user_bb['lat_max']
|
|
)
|
|
else:
|
|
# Ora crea un BBox che sia l'unione del BBox utente e del BBox che riempie il canvas.
|
|
# Questo assicura che il BBox utente sia dentro E che il canvas sia riempito.
|
|
final_west = min(user_bb['lon_min'], canvas_fill_bbox[0])
|
|
final_south = min(user_bb['lat_min'], canvas_fill_bbox[1])
|
|
final_east = max(user_bb['lon_max'], canvas_fill_bbox[2])
|
|
final_north = max(user_bb['lat_max'], canvas_fill_bbox[3])
|
|
map_fetch_geo_bounds_for_tiles_tuple = (final_west, final_south, final_east, final_north)
|
|
logger.debug(f"Combined BBox (user & canvas fill) for tile fetching: {map_fetch_geo_bounds_for_tiles_tuple} at zoom {self._current_zoom}")
|
|
|
|
else: # Caso 2: Pan/Zoom interattivo, non c'è un "ensure_bbox_is_covered_dict"
|
|
map_fetch_geo_bounds_for_tiles_tuple = 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_tuple:
|
|
logger.debug(f"Tile fetching for interactive pan/zoom (canvas-fill): {map_fetch_geo_bounds_for_tiles_tuple} at zoom {self._current_zoom}")
|
|
|
|
if not map_fetch_geo_bounds_for_tiles_tuple:
|
|
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_tuple, 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_tuple}. 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_tuple
|
|
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._current_map_geo_bounds}")
|
|
self._map_pil_image = stitched_map_pil
|
|
#self._redraw_canvas_content()
|
|
|
|
if self._map_pil_image: # Solo se la mappa è stata caricata con successo
|
|
self._redraw_canvas_content()
|
|
if self.app_controller and hasattr(self.app_controller, 'update_general_map_info'):
|
|
self.app_controller.update_general_map_info() # Notifica il controller per aggiornare il pannello
|
|
else:
|
|
# Se la mappa non è stata caricata, il controller potrebbe voler mostrare "N/A"
|
|
if self.app_controller and hasattr(self.app_controller, 'update_general_map_info'):
|
|
self.app_controller.update_general_map_info() # Invia comunque per aggiornare con N/A se necessario
|
|
|
|
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}")
|
|
self.recenter_and_redraw(target_center_lat, target_center_lon, new_zoom)
|
|
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
|
|
# Salva il punto di inizio del drag in coordinate CANVAS
|
|
self._drag_start_x_canvas = event.x
|
|
self._drag_start_y_canvas = event.y
|
|
# Salva anche il centro geografico corrente all'inizio del drag
|
|
if self._current_center_lat is not None and self._current_center_lon is not None:
|
|
self._drag_start_center_lon = self._current_center_lon
|
|
self._drag_start_center_lat = self._current_center_lat
|
|
else: # Non dovrebbe succedere se la mappa è visualizzata
|
|
self._drag_start_center_lon = None
|
|
self._drag_start_center_lat = None
|
|
|
|
self._is_dragging = False # Non considerarlo un drag finché non c'è movimento
|
|
self.canvas.config(cursor="fleur")
|
|
logger.debug(f"Mouse button press at ({event.x}, {event.y}). Ready for potential pan.")
|
|
|
|
def _on_mouse_drag(self, event: tk.Event):
|
|
if self._drag_start_x_canvas is None or self._drag_start_y_canvas is None or \
|
|
self._drag_start_center_lon is None or self._drag_start_center_lat is None or \
|
|
self._current_map_geo_bounds is None or not self.canvas.winfo_exists() or self._current_zoom is None:
|
|
return
|
|
|
|
self._is_dragging = True # Il mouse si è mosso mentre era premuto
|
|
|
|
# --- Logica per visualizzare un'anteprima del pan (OPZIONALE e più complesso) ---
|
|
# Si potrebbe spostare l'immagine PhotoImage esistente sul canvas SPOSTANDO l'item_id
|
|
# self.canvas.move(self._canvas_image_id, dx_pixel, dy_pixel)
|
|
# Questo però non ricarica le tile, mostra solo la stessa immagine spostata.
|
|
# Per ora, omettiamo l'anteprima per semplicità e ridisegniamo solo al rilascio.
|
|
# Potremmo cambiare il cursore per indicare il drag.
|
|
# logger.debug(f"Dragging to ({event.x}, {event.y})") # Troppo verboso
|
|
pass
|
|
|
|
def _on_mouse_button_release(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists():
|
|
self.canvas.config(cursor="")
|
|
return
|
|
|
|
self.canvas.config(cursor="") # Ripristina cursore
|
|
|
|
if not self._is_dragging: # Se non c'è stato drag, è stato un click semplice
|
|
logger.debug("Mouse button release without dragging (simple click). Action handled by specific button bindings (e.g., right-click).")
|
|
self._drag_start_x_canvas = None # Resetta per sicurezza
|
|
self._drag_start_y_canvas = None
|
|
self._drag_start_center_lon = None
|
|
self._drag_start_center_lat = None
|
|
self._is_dragging = False
|
|
return
|
|
|
|
# Se c'è stato un drag (self._is_dragging è True)
|
|
logger.debug(f"Mouse button release after drag to ({event.x}, {event.y}). Finalizing pan.")
|
|
|
|
if self._drag_start_x_canvas is None or self._drag_start_y_canvas is None or \
|
|
self._drag_start_center_lon is None or self._drag_start_center_lat is None or \
|
|
self._current_map_geo_bounds is None or self._current_zoom is None :
|
|
logger.warning("Cannot finalize pan: drag start state or map context is missing.")
|
|
self._is_dragging = False
|
|
self._drag_start_x_canvas = None
|
|
self._drag_start_y_canvas = None
|
|
return
|
|
|
|
dx_pixel = event.x - self._drag_start_x_canvas
|
|
dy_pixel = event.y - self._drag_start_y_canvas
|
|
|
|
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 finalize pan, canvas dimensions are zero.")
|
|
self._is_dragging = False
|
|
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
|
|
|
|
# Il nuovo centro è calcolato rispetto al centro che avevamo ALL'INIZIO del drag
|
|
new_center_lon = self._drag_start_center_lon + delta_lon
|
|
new_center_lat = self._drag_start_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))
|
|
|
|
logger.info(f"Pan finalized. Old center: ({self._drag_start_center_lat:.4f}, {self._drag_start_center_lon:.4f}), New center: ({new_center_lat:.4f}, {new_center_lon:.4f})")
|
|
|
|
# Ridisegna la mappa con il nuovo centro, mantenendo lo zoom.
|
|
# Non c'è un "ensure_bbox_is_covered" specifico per il pan.
|
|
self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom, ensure_bbox_is_covered_dict=None) #MODIFICATO nome parametro
|
|
|
|
self._is_dragging = False
|
|
self._drag_start_x_canvas = None
|
|
self._drag_start_y_canvas = None
|
|
self._drag_start_center_lon = None
|
|
self._drag_start_center_lat = None
|
|
|
|
def _on_right_click(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists(): return
|
|
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 |