SXXXXXXX_FlightMonitor/flightmonitor/map/map_canvas_manager.py
VALLONGOL 9ae7c54631 Chore: Stop tracking files based on .gitignore update.
Untracked files matching the following rules:
- Rule "!.vscode/launch.json": 1 file
2025-05-30 11:22:51 +02:00

1973 lines
86 KiB
Python

# flightmonitor/map/map_canvas_manager.py
import tkinter as tk
import math
import time
from typing import Optional, Tuple, List, Dict, Any
from collections import deque
import queue # For Queue and Empty
import threading
import copy # For deepcopy
try:
from PIL import Image, ImageTk, ImageDraw, ImageFont
PIL_IMAGE_LIB_AVAILABLE = True
except ImportError:
Image, ImageTk, ImageDraw, ImageFont = None, None, None, None # type: ignore
PIL_IMAGE_LIB_AVAILABLE = False
import logging
logging.error(
"MapCanvasManager: Pillow (Image, ImageTk, ImageDraw, ImageFont) not found. Map disabled."
)
try:
import pyproj
PYPROJ_MODULE_LOCALLY_AVAILABLE = True
except ImportError:
pyproj = None # type: ignore
PYPROJ_MODULE_LOCALLY_AVAILABLE = False
import logging
logging.warning(
"MapCanvasManager: 'pyproj' not found. Geographic calculations impaired."
)
try:
import mercantile
MERCANTILE_MODULE_LOCALLY_AVAILABLE = True
except ImportError:
mercantile = None # type: ignore
MERCANTILE_MODULE_LOCALLY_AVAILABLE = False
import logging
logging.error(
"MapCanvasManager: 'mercantile' not found. Tile conversions fail, map unusable."
)
from . import map_constants
from ..data import config as app_config
from ..data.common_models import CanonicalFlightState
from .map_services import BaseMapService, OpenStreetMapService
from .map_tile_manager import MapTileManager
from .map_utils import (
get_tile_ranges_for_bbox,
calculate_geographic_bbox_size_km,
calculate_geographic_bbox_from_pixel_size_and_zoom,
_is_valid_bbox_dict,
_pixel_to_geo,
calculate_meters_per_pixel,
calculate_zoom_level_for_geographic_size,
get_bounding_box_from_center_size,
)
from . import map_drawing # Importa il modulo map_drawing
try:
from ..utils.logger import get_logger
logger = get_logger(__name__)
except ImportError:
import logging
logger = logging.getLogger(__name__)
logger.warning("MapCanvasManager using fallback standard Python logger.")
CANVAS_SIZE_HARD_FALLBACK_PX = getattr(app_config, "DEFAULT_CANVAS_WIDTH", 800)
MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr(
app_config, "MAP_TILE_CACHE_DIR", "flightmonitor_tile_cache_fallback"
)
RESIZE_DEBOUNCE_DELAY_MS = 250
PAN_STEP_FRACTION = 0.25
DEFAULT_MAX_TRACK_POINTS = getattr(
app_config, "DEFAULT_TRACK_HISTORY_POINTS", 20
) # Legge da config
DEFAULT_MAX_TRACK_AGE_SECONDS = 300 # Puoi rendere anche questo configurabile se vuoi
MAP_WORKER_QUEUE_TIMEOUT_S = 0.1
GUI_RESULT_POLL_INTERVAL_MS = 50
RENDER_REQUEST_TYPE_MAP = "render_map"
RENDER_REQUEST_TYPE_SHUTDOWN = "shutdown_worker"
class MapCanvasManager:
def __init__(
self,
app_controller: Any,
tk_canvas: tk.Canvas,
initial_bbox_dict: Dict[str, float],
):
logger.info(">>> MapCanvasManager __init__ STARTING <<<")
if (
not PIL_IMAGE_LIB_AVAILABLE
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
or mercantile is None
):
critical_msg = "MapCanvasManager critical dependencies missing: Pillow or Mercantile. Map disabled."
logger.critical(critical_msg)
if app_controller and hasattr(app_controller, "show_error_message"):
try:
app_controller.show_error_message(
"Map Initialization Error", critical_msg
)
except Exception:
pass
raise ImportError(critical_msg)
self.app_controller = app_controller
self.canvas = tk_canvas
self.canvas_width = self.canvas.winfo_width()
if self.canvas_width <= 1:
self.canvas_width = CANVAS_SIZE_HARD_FALLBACK_PX
self.canvas_height = self.canvas.winfo_height()
if self.canvas_height <= 1:
self.canvas_height = getattr(app_config, "DEFAULT_CANVAS_HEIGHT", 600)
logger.info(
f"MCM __init__: Canvas dims {self.canvas_width}x{self.canvas_height}"
)
self._current_center_lat_gui: Optional[float] = None
self._current_center_lon_gui: Optional[float] = None
self._current_zoom_gui: int = map_constants.DEFAULT_INITIAL_ZOOM
self._current_map_geo_bounds_gui: Optional[
Tuple[float, float, float, float]
] = None
self._target_bbox_input_gui: Optional[Dict[str, float]] = None
self._map_photo_image: Optional[ImageTk.PhotoImage] = None
self._canvas_image_id: Optional[int] = None
self._placeholder_text_id: Optional[int] = None
self.map_service: BaseMapService = OpenStreetMapService()
self.tile_manager: MapTileManager = MapTileManager(
map_service=self.map_service,
cache_root_directory=MAP_TILE_CACHE_DIR_HARD_FALLBACK, # Questo ora usa app_config
tile_pixel_size=self.map_service.tile_size,
)
logger.info(f"MCM __init__: MapTileManager initialized.")
self._current_flights_to_display_gui: List[CanonicalFlightState] = []
self.flight_tracks_gui: Dict[str, deque] = (
{}
) # Deque conterrà tuple (lat, lon, timestamp)
self.max_track_points: int = DEFAULT_MAX_TRACK_POINTS # Inizializzato da config
self.max_track_age_seconds: float = DEFAULT_MAX_TRACK_AGE_SECONDS
self._resize_debounce_job_id: Optional[str] = None
self._map_render_request_queue: queue.Queue = queue.Queue(maxsize=5)
self._map_render_result_queue: queue.Queue = queue.Queue(maxsize=5)
self._map_worker_stop_event: threading.Event = threading.Event()
self._map_worker_thread: Optional[threading.Thread] = None
self._gui_after_id_result_processor: Optional[str] = None
self._last_render_request_id: int = 0
self._expected_render_id_gui: int = 0
self._map_data_lock: threading.Lock = (
threading.Lock()
) # Lock per dati condivisi
logger.info("MCM __init__: All attributes initialized.")
logger.info("MCM __init__: Attempting to start map worker thread...")
self._start_map_worker_thread()
logger.info(f"MCM __init__: Processing initial_bbox_dict: {initial_bbox_dict}")
if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict):
self._target_bbox_input_gui = initial_bbox_dict.copy()
logger.info(
f"MCM __init__: Valid initial_bbox_dict provided. Requesting render for bbox."
)
self._request_map_render_for_bbox(
initial_bbox_dict, preserve_current_zoom_if_possible=False
)
else:
logger.warning(
f"MCM __init__: Invalid or no 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_gui = default_bbox_cfg.copy()
logger.info(
f"MCM __init__: Using default config BBox. Requesting render for this bbox."
)
self._request_map_render_for_bbox(
self._target_bbox_input_gui, preserve_current_zoom_if_possible=False
)
else:
logger.critical(
f"MCM __init__: Default fallback BBox from config is invalid: {default_bbox_cfg}. Map cannot initialize view with specific bbox."
)
self._target_bbox_input_gui = None
self._current_center_lat_gui = getattr(
app_config, "DEFAULT_MAP_CENTER_LAT", 45.0
)
self._current_center_lon_gui = getattr(
app_config, "DEFAULT_MAP_CENTER_LON", 9.0
)
self._current_zoom_gui = map_constants.DEFAULT_INITIAL_ZOOM
logger.info(
f"MCM __init__: Using hardcoded default center/zoom. Requesting render."
)
self._request_map_render(
self._current_center_lat_gui,
self._current_center_lon_gui,
self._current_zoom_gui,
)
self._setup_event_bindings()
logger.info("MCM __init__: Event bindings set up.")
logger.info("MCM __init__: Attempting to start GUI result processing...")
self._start_gui_result_processing()
logger.info(">>> MapCanvasManager __init__ FINISHED <<<")
def _start_map_worker_thread(self):
if self._map_worker_thread is not None and self._map_worker_thread.is_alive():
logger.warning("Map worker thread already running.")
return
self._map_worker_stop_event.clear()
self._map_worker_thread = threading.Thread(
target=self._map_render_worker_target, name="MapRenderWorker", daemon=True
)
self._map_worker_thread.start()
logger.info("MapRenderWorker thread started successfully.")
def _map_render_worker_target(self):
worker_initial_settle_delay_seconds = 0.1
thread_name = threading.current_thread().name
logger.info(
f"{thread_name}: Target loop initiated, starting initial settle delay ({worker_initial_settle_delay_seconds}s)..."
)
if self._map_worker_stop_event.wait(
timeout=worker_initial_settle_delay_seconds
):
logger.info(
f"{thread_name}: Received stop signal during initial settle. Terminating worker target."
)
return
logger.info(
f"{thread_name}: Initial settle delay complete. Worker entering main request loop."
)
while not self._map_worker_stop_event.is_set():
request_data = None
request_id = -1
try:
logger.debug(
f"{thread_name}: Waiting for request from queue (timeout {MAP_WORKER_QUEUE_TIMEOUT_S}s)..."
)
request_data = self._map_render_request_queue.get(
timeout=MAP_WORKER_QUEUE_TIMEOUT_S
)
request_type = request_data.get("type")
request_id = request_data.get("request_id", -1)
logger.info(
f"{thread_name}: Dequeued request. Type: '{request_type}', ID: {request_id}"
)
if request_type == RENDER_REQUEST_TYPE_SHUTDOWN:
logger.info(
f"{thread_name}: Received RENDER_REQUEST_TYPE_SHUTDOWN. Exiting loop."
)
self._map_render_request_queue.task_done()
break
if request_type == RENDER_REQUEST_TYPE_MAP:
logger.info(
f"{thread_name}: Processing RENDER_REQUEST_TYPE_MAP for ID: {request_id}"
)
center_lat = request_data.get("center_lat")
center_lon = request_data.get("center_lon")
zoom = request_data.get("zoom")
canvas_w = request_data.get("canvas_width")
canvas_h = request_data.get("canvas_height")
if None in [center_lat, center_lon, zoom, canvas_w, canvas_h]:
logger.error(
f"{thread_name}: Missing critical parameters in request ID {request_id}. Aborting. Data: {request_data}"
)
error_payload = {
"request_id": request_id,
"photo_image": None,
"map_geo_bounds": None,
"error": "Worker: Missing critical render parameters.",
}
self._map_render_result_queue.put(error_payload)
self._map_render_request_queue.task_done()
continue
logger.debug(
f"{thread_name}: Parameters for ID {request_id} - Center:({center_lat:.3f},{center_lon:.3f}), Z:{zoom}, Canvas:{canvas_w}x{canvas_h}"
)
target_bbox = request_data.get("target_bbox")
# flights_to_draw e tracks_to_draw vengono passati come copie
flights_to_draw = request_data.get("flights", [])
tracks_to_draw = request_data.get(
"tracks", {}
) # Questa è una dict[str, deque]
max_track_pts_from_req = request_data.get(
"max_track_points", DEFAULT_MAX_TRACK_POINTS
)
photo_image_result, actual_map_bounds, error_message = (
self._execute_render_pipeline(
center_lat,
center_lon,
zoom,
canvas_w,
canvas_h,
target_bbox,
flights_to_draw,
tracks_to_draw,
max_track_pts_from_req,
)
)
if self._map_worker_stop_event.is_set():
logger.info(
f"{thread_name}: Stop event set after render pipeline for ID {request_id}. Discarding result."
)
break
result_payload = {
"request_id": request_id,
"photo_image": photo_image_result,
"map_geo_bounds": actual_map_bounds,
"error": error_message,
}
logger.debug(
f"{thread_name}: Attempting to put result for ID {request_id} into queue (Queue size: {self._map_render_result_queue.qsize()})."
)
self._map_render_result_queue.put(result_payload)
logger.info(
f"{thread_name}: Successfully put result for ID {request_id} into queue."
)
else:
logger.warning(
f"{thread_name}: Received unknown request type '{request_type}' for ID {request_id}."
)
self._map_render_request_queue.task_done()
except queue.Empty:
logger.debug(
f"{thread_name}: Request queue empty, looping to check stop event."
)
continue
except Exception as e:
logger.exception(
f"{thread_name}: Unhandled exception in worker loop for request ID {request_id if request_data else 'N/A'}: {e}"
)
if request_data and request_id != -1:
error_payload_exc = {
"request_id": request_id,
"photo_image": None,
"map_geo_bounds": None,
"error": f"Worker loop unhandled exception: {type(e).__name__}",
}
try:
self._map_render_result_queue.put_nowait(error_payload_exc)
logger.info(
f"{thread_name}: Reported unhandled exception for ID {request_id} to result queue."
)
except queue.Full:
logger.error(
f"{thread_name}: Result queue full while trying to report worker unhandled exception."
)
except Exception as e_put_err:
logger.error(
f"{thread_name}: Error putting unhandled exception report to result queue: {e_put_err}"
)
if request_data:
try:
self._map_render_request_queue.task_done()
except ValueError:
pass
time.sleep(0.5)
logger.info(f"{thread_name}: Worker thread target loop finished.")
def _start_gui_result_processing(self):
if self._gui_after_id_result_processor:
try:
if self.canvas.winfo_exists():
self.canvas.after_cancel(self._gui_after_id_result_processor)
except Exception as e_cancel:
logger.warning(
f"Error cancelling previous result processor: {e_cancel}"
)
if self.canvas.winfo_exists():
self._gui_after_id_result_processor = self.canvas.after(
GUI_RESULT_POLL_INTERVAL_MS, self._process_map_render_results
)
logger.debug("GUI result processing scheduled.")
else:
logger.warning(
"Canvas does not exist, cannot schedule GUI result processing."
)
def _execute_render_pipeline(
self,
center_lat: float,
center_lon: float,
zoom_level: int,
canvas_w: int,
canvas_h: int,
target_bbox_input: Optional[Dict[str, float]],
current_flights_to_display: List[CanonicalFlightState],
flight_tracks: Dict[
str, deque
], # flight_tracks è Dict[str, deque[Tuple[float,float,float]]]
max_track_points_config: int, # Max track points dalla configurazione/GUI
) -> Tuple[
Optional[ImageTk.PhotoImage],
Optional[Tuple[float, float, float, float]],
Optional[str],
]:
logger.debug(
f"WorkerPipeline: Starting for Z{zoom_level}, Center ({center_lat:.4f},{center_lon:.4f}), Canvas {canvas_w}x{canvas_h}"
)
if (
not PIL_IMAGE_LIB_AVAILABLE
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
or mercantile is None
or Image is None
or ImageDraw is None
or ImageTk is None
):
err_msg = "WorkerPipeline: Pillow/Mercantile/ImageTk dependencies missing."
logger.error(err_msg)
return None, None, err_msg
if canvas_w <= 0 or canvas_h <= 0:
err_msg = (
f"WorkerPipeline: Invalid canvas dimensions ({canvas_w}x{canvas_h})."
)
logger.error(err_msg)
return None, None, err_msg
canvas_geo_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom(
center_lat,
center_lon,
canvas_w,
canvas_h,
zoom_level,
self.tile_manager.tile_size,
)
if not canvas_geo_bbox:
err_msg = "WorkerPipeline: Failed to calculate canvas geographic BBox."
logger.error(err_msg)
return None, None, err_msg
logger.debug(f"WorkerPipeline: Calculated canvas_geo_bbox: {canvas_geo_bbox}")
tile_xy_ranges = get_tile_ranges_for_bbox(canvas_geo_bbox, zoom_level)
if not tile_xy_ranges:
err_msg = f"WorkerPipeline: Failed to get tile ranges for {canvas_geo_bbox} at Z{zoom_level}."
logger.error(err_msg)
try:
placeholder_img = Image.new(
"RGB",
(canvas_w, canvas_h),
map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB,
)
draw = ImageDraw.Draw(placeholder_img)
map_drawing._draw_text_on_placeholder(
draw, placeholder_img.size, err_msg.replace("WorkerPipeline: ", "")
)
if ImageTk:
return ImageTk.PhotoImage(placeholder_img), None, err_msg
else:
return (
None,
None,
"WorkerPipeline: ImageTk unavailable for placeholder.",
)
except Exception as e_ph:
err_msg_ph = f"WorkerPipeline: Tile range error AND placeholder creation failed: {e_ph}"
logger.error(err_msg_ph)
return None, None, err_msg_ph
logger.debug(f"WorkerPipeline: Calculated tile_xy_ranges: {tile_xy_ranges}")
stitched_map_pil = self.tile_manager.stitch_map_image(
zoom_level, tile_xy_ranges[0], tile_xy_ranges[1]
)
if not stitched_map_pil:
err_msg = "WorkerPipeline: Failed to stitch map image from TileManager."
logger.error(err_msg)
return None, None, err_msg
logger.debug(
f"WorkerPipeline: Successfully stitched base map image {stitched_map_pil.size}."
)
actual_stitched_map_geo_bounds = self.tile_manager._get_bounds_for_tile_range(
zoom_level, tile_xy_ranges
)
if not actual_stitched_map_geo_bounds:
logger.warning(
"WorkerPipeline: Could not determine actual stitched map geo bounds. Using canvas_geo_bbox as fallback."
)
actual_stitched_map_geo_bounds = canvas_geo_bbox
logger.debug(
f"WorkerPipeline: Actual stitched map geo bounds: {actual_stitched_map_geo_bounds}"
)
if stitched_map_pil.mode != "RGBA":
image_to_draw_on = stitched_map_pil.convert("RGBA")
else:
image_to_draw_on = stitched_map_pil.copy()
img_shape = image_to_draw_on.size # (width, height)
draw = ImageDraw.Draw(image_to_draw_on)
logger.debug(
f"WorkerPipeline: Prepared image for drawing overlays, size {img_shape}."
)
if target_bbox_input and _is_valid_bbox_dict(target_bbox_input):
bbox_wesn = (
target_bbox_input["lon_min"],
target_bbox_input["lat_min"],
target_bbox_input["lon_max"],
target_bbox_input["lat_max"],
)
try:
map_drawing.draw_area_bounding_box( # Usa la funzione da map_drawing
image_to_draw_on,
bbox_wesn,
actual_stitched_map_geo_bounds,
img_shape,
color=map_constants.AREA_BOUNDARY_COLOR,
thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX,
)
logger.debug(f"WorkerPipeline: Drew target BBox.")
except Exception as e_bbox_draw_pipe:
logger.error(
f"WorkerPipeline: Error drawing target BBox: {e_bbox_draw_pipe}",
exc_info=False,
)
# Disegna voli e le loro tracce
if current_flights_to_display:
font_size = map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE + (
zoom_level - map_constants.DEM_TILE_LABEL_BASE_ZOOM
)
label_font = map_drawing._load_label_font(
font_size
) # Usa la funzione da map_drawing
flights_drawn_count = 0
for flight in current_flights_to_display:
if flight.latitude is not None and flight.longitude is not None:
pixel_coords_f = map_drawing._geo_to_pixel_on_unscaled_map( # Usa la funzione da map_drawing
flight.latitude,
flight.longitude,
actual_stitched_map_geo_bounds,
img_shape,
)
if pixel_coords_f:
try:
current_flight_track_deque = flight_tracks.get(
flight.icao24
) # Prende la deque dal dict passato
map_drawing._draw_single_flight( # Usa la funzione da map_drawing
draw,
pixel_coords_f,
flight,
label_font,
track_deque=current_flight_track_deque,
current_map_geo_bounds=actual_stitched_map_geo_bounds,
current_stitched_map_pixel_shape=img_shape,
# max_track_points_config e track_line_width sono ora letti globalmente in map_drawing
)
flights_drawn_count += 1
except Exception as e_flight_draw_pipe:
logger.error(
f"WorkerPipeline: Error drawing flight {flight.icao24}: {e_flight_draw_pipe}",
exc_info=False,
)
logger.debug(
f"WorkerPipeline: Drew {flights_drawn_count} flight markers and tracks."
)
try:
if ImageTk:
final_photo_image = ImageTk.PhotoImage(image_to_draw_on)
logger.debug(f"WorkerPipeline: Successfully created PhotoImage.")
return final_photo_image, actual_stitched_map_geo_bounds, None
else:
return (
None,
actual_stitched_map_geo_bounds,
"WorkerPipeline: ImageTk module not available.",
)
except Exception as e_photo_pipe:
err_msg_photo = (
f"WorkerPipeline: Failed to create PhotoImage: {e_photo_pipe}"
)
logger.error(err_msg_photo, exc_info=True)
return None, actual_stitched_map_geo_bounds, err_msg_photo
def _process_map_render_results(self):
if not self.canvas.winfo_exists():
logger.info("Canvas destroyed, stopping map render result processing.")
self._gui_after_id_result_processor = None
return
logger.debug(
f"GUI ResultsProcessor: Checking result queue. Expected ID >= {self._expected_render_id_gui}. Queue size: {self._map_render_result_queue.qsize()}"
)
processed_one = False
try:
while not self._map_render_result_queue.empty():
result_data = self._map_render_result_queue.get_nowait()
processed_one = True
request_id = result_data.get("request_id")
photo_image = result_data.get("photo_image")
rendered_map_bounds = result_data.get("map_geo_bounds")
error_message = result_data.get("error")
logger.info(
f"GUI ResultsProcessor: Dequeued result for ReqID {request_id}. Error: '{error_message}', Img Valid: {photo_image is not None}, Expected GUI ID: {self._expected_render_id_gui}"
)
if request_id < self._expected_render_id_gui:
logger.warning(
f"GUI ResultsProcessor: Discarding STALE map render result ID {request_id} (expected >= {self._expected_render_id_gui})."
)
self._map_render_result_queue.task_done()
continue
if error_message:
logger.error(
f"GUI ResultsProcessor: Received error from MapRenderWorker (ReqID {request_id}): {error_message}"
)
self._display_placeholder_text(
f"Map Error (Worker):\n{error_message[:100]}"
)
elif (
photo_image
and ImageTk
and isinstance(photo_image, ImageTk.PhotoImage)
):
logger.info(
f"GUI ResultsProcessor: Applying new map image from worker for ReqID {request_id}."
)
self._clear_canvas_display_elements()
self._map_photo_image = photo_image
self._canvas_image_id = self.canvas.create_image(
0, 0, anchor=tk.NW, image=self._map_photo_image
)
self._current_map_geo_bounds_gui = rendered_map_bounds
logger.debug(
f"GUI ResultsProcessor: Canvas updated with image for ReqID {request_id}. New map bounds: {self._current_map_geo_bounds_gui}"
)
else:
logger.warning(
f"GUI ResultsProcessor: Received invalid/empty result from worker for ReqID {request_id}. No PhotoImage. Error was: '{error_message}'"
)
self._display_placeholder_text(
f"Map Update Failed\n(No Image Data for ReqID {request_id})"
)
self._map_render_result_queue.task_done()
if self.app_controller and hasattr(
self.app_controller, "update_general_map_info"
):
logger.debug(
f"GUI ResultsProcessor: Requesting controller to update general map info after processing ReqID {request_id}."
)
self.app_controller.update_general_map_info()
except queue.Empty:
logger.debug("GUI ResultsProcessor: Result queue is empty.")
pass
except Exception as e:
logger.exception(
f"GUI ResultsProcessor: Error processing map render results: {e}"
)
if self.canvas.winfo_exists():
self._gui_after_id_result_processor = self.canvas.after(
GUI_RESULT_POLL_INTERVAL_MS, self._process_map_render_results
)
logger.debug(
f"GUI ResultsProcessor: Rescheduled itself. Processed one this cycle: {processed_one}"
)
else:
logger.info("GUI ResultsProcessor: Canvas gone, not rescheduling.")
def _request_map_render(
self,
center_lat: float,
center_lon: float,
zoom_level: int,
ensure_bbox_is_covered_dict: Optional[Dict[str, float]] = None,
):
logger.debug(
f"GUI _request_map_render: Input center=({center_lat:.4f},{center_lon:.4f}), zoom={zoom_level}"
)
if (
self._map_worker_stop_event.is_set()
or not self._map_worker_thread
or not self._map_worker_thread.is_alive()
):
logger.warning("Map worker not running. Cannot queue render request.")
self._display_placeholder_text("Map Worker Offline")
return
with self._map_data_lock:
self._last_render_request_id += 1
current_request_id = self._last_render_request_id
self._expected_render_id_gui = current_request_id
logger.info(
f"GUI _request_map_render: New request ID {current_request_id}. Expected GUI ID set to {self._expected_render_id_gui}."
)
# Copia i dati condivisi sotto lock
flights_copy = []
try:
flights_copy = copy.deepcopy(self._current_flights_to_display_gui)
except Exception as e_copy_f:
logger.error(
f"Error deepcopying flights for render request: {e_copy_f}"
)
tracks_copy = {}
try:
tracks_copy = copy.deepcopy(
self.flight_tracks_gui
) # Deepcopy delle code
except Exception as e_copy_t:
logger.error(f"Error deepcopying tracks for render request: {e_copy_t}")
target_bbox_to_send = None
if ensure_bbox_is_covered_dict:
try:
target_bbox_to_send = copy.deepcopy(ensure_bbox_is_covered_dict)
except Exception as e_copy_b_ensure:
logger.error(
f"Error deepcopying ensure_bbox for render request: {e_copy_b_ensure}"
)
elif self._target_bbox_input_gui:
try:
target_bbox_to_send = copy.deepcopy(self._target_bbox_input_gui)
except Exception as e_copy_b_target:
logger.error(
f"Error deepcopying target_bbox_input for render request: {e_copy_b_target}"
)
request_payload = {
"type": RENDER_REQUEST_TYPE_MAP,
"request_id": current_request_id,
"center_lat": center_lat,
"center_lon": center_lon,
"zoom": zoom_level,
"canvas_width": self.canvas_width,
"canvas_height": self.canvas_height,
"target_bbox": target_bbox_to_send,
"flights": flights_copy,
"tracks": tracks_copy,
"max_track_points": self.max_track_points,
}
logger.debug(
f"GUI _request_map_render: Assembled payload for ReqID {current_request_id}: Center=({request_payload['center_lat']:.3f},{request_payload['center_lon']:.3f}), Z={request_payload['zoom']}, Canvas={request_payload['canvas_width']}x{request_payload['canvas_height']}, TargetBBox provided: {request_payload['target_bbox'] is not None}, NumFlights: {len(flights_copy)}, NumTracks: {len(tracks_copy)}"
)
try:
self._clear_canvas_display_elements()
loading_text = f"Loading Map...\nZ{zoom_level} @ ({center_lat:.2f}, {center_lon:.2f})\nReqID: {current_request_id}"
logger.debug(
f"GUI _request_map_render: Displaying placeholder: '{loading_text}'"
)
self._display_placeholder_text(loading_text)
logger.debug(
f"GUI _request_map_render: Attempting to put ReqID {current_request_id} in request queue (current qsize: {self._map_render_request_queue.qsize()})."
)
self._map_render_request_queue.put_nowait(request_payload)
logger.info(
f"GUI _request_map_render: Successfully queued map render request ID {current_request_id} (Z{zoom_level})."
)
self._current_center_lat_gui = center_lat
self._current_center_lon_gui = center_lon
self._current_zoom_gui = zoom_level
logger.debug(
f"GUI _request_map_render: Updated _gui state vars: center=({self._current_center_lat_gui},{self._current_center_lon_gui}), zoom={self._current_zoom_gui}"
)
except queue.Full:
logger.warning(
f"Map render request queue FULL. Request ID {current_request_id} was DROPPED."
)
with self._map_data_lock:
if (
self._expected_render_id_gui == current_request_id
): # Solo se era l'ultimo atteso
# Se la coda è piena, non possiamo garantire quale richiesta verrà elaborata.
# Non decrementare _expected_render_id_gui qui, perché la GUI si aspetta
# ancora questa richiesta. Il worker scarterà le richieste precedenti se ne arriva una nuova.
# Il problema è solo se la *coda* è piena.
pass # Non modificare _expected_render_id_gui, potrebbe essere ancora valido.
self._display_placeholder_text(
"Map Busy / Request Queue Full.\nPlease Try Action Again."
)
except Exception as e:
logger.exception(
f"GUI _request_map_render: Error queuing map render request ID {current_request_id}: {e}"
)
self._display_placeholder_text(
f"Error Queuing Map Request:\n{type(e).__name__}"
)
def _setup_event_bindings(self):
self.canvas.bind("<Configure>", self._on_canvas_resize)
self.canvas.bind("<ButtonPress-1>", self._on_left_button_press)
self.canvas.bind("<ButtonRelease-1>", self._on_left_button_release)
self.canvas.bind("<ButtonPress-3>", self._on_right_click)
self._drag_start_x_canvas: Optional[int] = None
self._drag_start_y_canvas: Optional[int] = None
self._is_left_button_pressed: bool = False
logger.debug("MCM Event bindings set up.")
def _on_canvas_resize(self, event: tk.Event):
new_width, new_height = event.width, event.height
logger.debug(
f"MCM _on_canvas_resize: Event triggered with new_width={new_width}, new_height={new_height}. Current canvas_width={self.canvas_width}, canvas_height={self.canvas_height}"
)
if (
new_width > 1
and new_height > 1
and (self.canvas_width != new_width or self.canvas_height != new_height)
):
logger.info(
f"MCM _on_canvas_resize: Canvas dimensions changed from {self.canvas_width}x{self.canvas_height} to {new_width}x{new_height}. Debouncing redraw."
)
if self._resize_debounce_job_id:
try:
if self.canvas.winfo_exists():
self.canvas.after_cancel(self._resize_debounce_job_id)
logger.debug(
f"MCM _on_canvas_resize: Cancelled previous debounce job: {self._resize_debounce_job_id}"
)
except Exception as e_cancel_resize:
logger.warning(
f"MCM _on_canvas_resize: Error cancelling previous resize job: {e_cancel_resize}"
)
if self.canvas.winfo_exists():
self._resize_debounce_job_id = self.canvas.after(
RESIZE_DEBOUNCE_DELAY_MS,
self._perform_resize_redraw,
new_width,
new_height,
)
logger.debug(
f"MCM _on_canvas_resize: Scheduled new debounce job: {self._resize_debounce_job_id} for {new_width}x{new_height}"
)
else:
logger.warning(
"MCM _on_canvas_resize: Canvas does not exist, cannot schedule resize redraw."
)
else:
logger.debug(
f"MCM _on_canvas_resize: Ignoring resize event (no change or invalid new dims: {new_width}x{new_height})."
)
def _perform_resize_redraw(self, width: int, height: int):
self._resize_debounce_job_id = None
if not self.canvas.winfo_exists():
logger.warning(
"MCM _perform_resize_redraw: Canvas does not exist. Aborting."
)
return
logger.info(
f"MCM _perform_resize_redraw: Executing debounced resize to {width}x{height}. Requesting new render."
)
self.canvas_width, self.canvas_height = width, height
logger.debug(
f"MCM _perform_resize_redraw: Updated canvas_width={self.canvas_width}, canvas_height={self.canvas_height}"
)
if (
self._current_center_lat_gui is not None
and self._current_center_lon_gui is not None
and self._current_zoom_gui is not None
):
logger.debug(
f"MCM _perform_resize_redraw: Requesting render with current GUI state: center=({self._current_center_lat_gui},{self._current_center_lon_gui}), zoom={self._current_zoom_gui}"
)
self._request_map_render(
self._current_center_lat_gui,
self._current_center_lon_gui,
self._current_zoom_gui,
ensure_bbox_is_covered_dict=self._target_bbox_input_gui,
)
else:
logger.warning(
"MCM _perform_resize_redraw: Cannot redraw on resize - current map center/zoom not set in GUI state."
)
self._display_placeholder_text("Map State Error\n(Cannot redraw on resize)")
def set_max_track_points(self, length: int):
new_length = max(2, length)
logger.debug(
f"MCM set_max_track_points: Requested length {length}, effective new_length {new_length}. Current: {self.max_track_points}"
)
if self.max_track_points != new_length:
logger.info(
f"MapCanvasManager: Max track points updating from {self.max_track_points} to {new_length}"
)
with self._map_data_lock: # Proteggere flight_tracks_gui
self.max_track_points = (
new_length # Aggiorna l'attributo usato dal worker
)
logger.debug(
f"MCM set_max_track_points: Trimming existing GUI tracks (count: {len(self.flight_tracks_gui)})."
)
for icao in list(
self.flight_tracks_gui.keys()
): # list() per evitare RuntimeError se si modifica il dict
track_deque = self.flight_tracks_gui.get(icao)
if track_deque:
# Crea una nuova deque con il maxlen corretto e gli ultimi N elementi
# Questo è più sicuro che modificare la deque esistente se il maxlen è cambiato
new_deque = deque(
maxlen=self.max_track_points + 5
) # Usa il nuovo max_track_points per la nuova deque
# Prendi gli ultimi N elementi dalla vecchia deque, fino al nuovo self.max_track_points
num_to_keep = min(len(track_deque), self.max_track_points)
for i in range(num_to_keep):
new_deque.append(
track_deque[len(track_deque) - num_to_keep + i]
)
if not new_deque: # Se dopo il trim è vuota
if icao in self.flight_tracks_gui:
del self.flight_tracks_gui[icao]
logger.debug(
f"Removed empty track for {icao} after trimming due to max_track_points change."
)
else:
self.flight_tracks_gui[icao] = new_deque
logger.debug(
f"Replaced track for {icao} with new deque of length {len(new_deque)}."
)
if (
self._current_center_lat_gui is not None
and self._current_center_lon_gui is not None
and self._current_zoom_gui is not None
):
logger.info(
"MCM set_max_track_points: Requesting map re-render due to track length change."
)
self._request_map_render(
self._current_center_lat_gui,
self._current_center_lon_gui,
self._current_zoom_gui,
)
else:
logger.warning(
"MCM set_max_track_points: Cannot request re-render as current view state is not set."
)
else:
logger.debug(
f"MapCanvasManager: Max track points already set to {new_length}. No change, no re-render requested by this method."
)
def set_target_bbox(self, new_bbox_dict: Dict[str, float]):
logger.info(
f"MCM set_target_bbox (GUI): Received new target BBox: {new_bbox_dict}"
)
if new_bbox_dict and _is_valid_bbox_dict(new_bbox_dict):
with self._map_data_lock: # Protegge _target_bbox_input_gui
self._target_bbox_input_gui = new_bbox_dict.copy()
logger.debug(
f"MCM set_target_bbox: _target_bbox_input_gui updated. Requesting render for this new bbox."
)
self._request_map_render_for_bbox(
self._target_bbox_input_gui, preserve_current_zoom_if_possible=False
)
if self.app_controller and hasattr(
self.app_controller, "update_bbox_gui_fields"
):
self.app_controller.update_bbox_gui_fields(self._target_bbox_input_gui)
else:
logger.warning(
f"MCM set_target_bbox: Invalid/empty new_bbox_dict: {new_bbox_dict}. Clearing _target_bbox_input_gui."
)
with self._map_data_lock:
self._target_bbox_input_gui = None
if (
self._current_center_lat_gui is not None
and self._current_center_lon_gui is not None
and self._current_zoom_gui is not None
):
logger.info(
f"MCM set_target_bbox: Target BBox cleared. Re-rendering at current view: center=({self._current_center_lat_gui},{self._current_center_lon_gui}), zoom={self._current_zoom_gui}"
)
self._request_map_render(
self._current_center_lat_gui,
self._current_center_lon_gui,
self._current_zoom_gui,
)
else:
logger.warning(
"MCM set_target_bbox: Target BBox cleared, but no current view to re-render. Clearing map display."
)
self.clear_map_display() # Mostrerà placeholder
if self.app_controller and hasattr(
self.app_controller, "update_bbox_gui_fields"
):
self.app_controller.update_bbox_gui_fields(
{}
) # Invia dict vuoto per resettare i campi GUI
def _request_map_render_for_bbox(
self,
target_bbox_dict: Dict[str, float],
preserve_current_zoom_if_possible: bool = False,
):
logger.debug(
f"MCM _request_map_render_for_bbox: target_bbox={target_bbox_dict}, preserve_zoom={preserve_current_zoom_if_possible}"
)
if not target_bbox_dict or not _is_valid_bbox_dict(target_bbox_dict):
logger.warning(
"_request_map_render_for_bbox called with invalid/no target BBox. Aborting."
)
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
): # Gestisce BBox che attraversano l'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
logger.debug(
f"MCM _request_map_render_for_bbox: Calculated BBox center: ({view_center_lat:.4f}, {view_center_lon:.4f})"
)
zoom_to_use = self._current_zoom_gui # Default alla zoom corrente
logger.debug(
f"MCM _request_map_render_for_bbox: Initial zoom_to_use (from _current_zoom_gui): {zoom_to_use}"
)
if not preserve_current_zoom_if_possible or zoom_to_use is None:
logger.debug(
f"MCM _request_map_render_for_bbox: Calculating new zoom (preserve_current_zoom_if_possible={preserve_current_zoom_if_possible}, zoom_to_use_is_None={zoom_to_use is None})"
)
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)
logger.debug(
f"MCM _request_map_render_for_bbox: BBox size: {patch_width_km}km x {patch_height_km}km"
)
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,
)
logger.debug(
f"MCM _request_map_render_for_bbox: Calculated zoom_w={zoom_w}, zoom_h={zoom_h}"
)
if zoom_w is not None and zoom_h is not None:
new_calc_zoom = min(zoom_w, zoom_h)
elif zoom_w is not None:
new_calc_zoom = zoom_w
elif zoom_h is not None:
new_calc_zoom = zoom_h
else:
new_calc_zoom = map_constants.DEFAULT_INITIAL_ZOOM
logger.warning(
f"MCM _request_map_render_for_bbox: Both zoom_w and zoom_h are None. Using default zoom {new_calc_zoom}."
)
zoom_to_use = new_calc_zoom
logger.info(
f"MCM _request_map_render_for_bbox: Calculated new zoom_to_use: {zoom_to_use}"
)
else:
logger.warning(
"MCM _request_map_render_for_bbox: Could not calculate BBox dimensions or canvas size invalid. Using default zoom."
)
zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
else:
logger.debug(
f"MCM _request_map_render_for_bbox: Preserving current zoom: {zoom_to_use}"
)
max_zoom_svc = (
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_svc))
logger.debug(
f"MCM _request_map_render_for_bbox: Final zoom_to_use after clamping: {zoom_to_use}"
)
self._request_map_render(
view_center_lat,
view_center_lon,
zoom_to_use,
ensure_bbox_is_covered_dict=target_bbox_dict,
)
def _clear_canvas_display_elements(self):
logger.debug(
"MCM _clear_canvas_display_elements: Clearing canvas image and placeholder text."
)
if self._canvas_image_id is not None and self.canvas.winfo_exists():
try:
self.canvas.delete(self._canvas_image_id)
except Exception as e_del_img:
logger.warning(
f"Error deleting canvas image ID {self._canvas_image_id}: {e_del_img}"
)
finally:
self._canvas_image_id = None
if self._map_photo_image is not None:
self._map_photo_image = None # Rimuove riferimento all'immagine
if self._placeholder_text_id is not None and self.canvas.winfo_exists():
try:
self.canvas.delete(self._placeholder_text_id)
except Exception as e_del_text:
logger.warning(
f"Error deleting placeholder text ID {self._placeholder_text_id}: {e_del_text}"
)
finally:
self._placeholder_text_id = None
logger.debug("MCM _clear_canvas_display_elements: Done.")
def _display_placeholder_text(self, text: str):
logger.debug(f"MCM _display_placeholder_text: Displaying '{text[:50]}...'")
if not self.canvas.winfo_exists():
logger.warning("MCM _display_placeholder_text: Canvas does not exist.")
return
self._clear_canvas_display_elements() # Assicura che il canvas sia pulito prima di disegnare il testo
try:
canvas_w_disp, canvas_h_disp = (
self.canvas.winfo_width(),
self.canvas.winfo_height(),
)
if canvas_w_disp <= 1:
canvas_w_disp = self.canvas_width # Fallback
if canvas_h_disp <= 1:
canvas_h_disp = self.canvas_height # Fallback
logger.debug(
f"MCM _display_placeholder_text: Using canvas_dims {canvas_w_disp}x{canvas_h_disp}"
)
if canvas_w_disp > 1 and canvas_h_disp > 1:
bg_color_to_use = getattr(
map_constants, "DEFAULT_PLACEHOLDER_COLOR_RGB_TK", "gray90"
)
self.canvas.configure(
bg=bg_color_to_use
) # Imposta il colore di sfondo del canvas
self._placeholder_text_id = self.canvas.create_text(
canvas_w_disp / 2,
canvas_h_disp / 2,
text=text,
fill="gray10",
font=("Arial", 11, "normal"),
justify=tk.CENTER,
width=max(100, canvas_w_disp - 40),
)
logger.debug(
f"MCM _display_placeholder_text: Placeholder text ID {self._placeholder_text_id} created."
)
else:
logger.warning(
f"MCM _display_placeholder_text: Cannot draw placeholder text, canvas dims invalid ({canvas_w_disp}x{canvas_h_disp})."
)
except tk.TclError as e_tcl_placeholder:
logger.warning(
f"MCM _display_placeholder_text: TclError displaying placeholder text (canvas might be gone): {e_tcl_placeholder}"
)
except Exception as e_placeholder:
logger.error(
f"MCM _display_placeholder_text: Unexpected error: {e_placeholder}",
exc_info=True,
)
def clear_map_display(self):
logger.info(
"MCM clear_map_display: Clearing all map content and resetting view state."
)
self._clear_canvas_display_elements()
self._display_placeholder_text("Map Cleared / Awaiting Action")
with self._map_data_lock:
self._current_flights_to_display_gui = []
self.flight_tracks_gui.clear()
self._current_map_geo_bounds_gui = None # L'immagine della mappa è sparita, quindi i suoi bounds non sono più validi
logger.debug(
"MCM clear_map_display: Internal data cleared (flights, tracks, map_bounds)."
)
# Forza un re-render della mappa base (senza voli/tracce) se la vista era impostata
if (
self._current_center_lat_gui is not None
and self._current_center_lon_gui is not None
and self._current_zoom_gui is not None
):
logger.info(
"MCM clear_map_display: Requesting re-render of clean base map."
)
self._request_map_render(
self._current_center_lat_gui,
self._current_center_lon_gui,
self._current_zoom_gui,
ensure_bbox_is_covered_dict=self._target_bbox_input_gui, # Mantiene il BBox di monitoraggio se era impostato
)
else:
# Se non c'è uno stato di vista precedente, il placeholder è sufficiente.
# L'utente dovrà interagire (es. impostare un BBox) per vedere una mappa.
logger.warning(
"MCM clear_map_display: No current view state to request clean render, placeholder remains."
)
# Assicuriamoci che le info sulla mappa vengano aggiornate per riflettere lo stato "pulito"
if self.app_controller and hasattr(
self.app_controller, "update_general_map_info"
):
self.app_controller.update_general_map_info()
def _on_left_button_press(self, event: tk.Event):
if not self.canvas.winfo_exists():
return
logger.debug(f"MCM _on_left_button_press: CanvasX={event.x}, CanvasY={event.y}")
self._drag_start_x_canvas, self._drag_start_y_canvas = event.x, event.y
self._is_left_button_pressed = True
def _on_left_button_release(self, event: tk.Event):
if not self.canvas.winfo_exists():
return
logger.debug(
f"MCM _on_left_button_release: CanvasX={event.x}, CanvasY={event.y}"
)
if not self._is_left_button_pressed:
logger.debug(
"MCM _on_left_button_release: Button was not pressed. Ignoring."
)
return
self._is_left_button_pressed = False
if (
self._drag_start_x_canvas is not None
and self._drag_start_y_canvas is not None
):
drag_thresh = 5
dx = abs(event.x - self._drag_start_x_canvas)
dy = abs(event.y - self._drag_start_y_canvas)
logger.debug(
f"MCM _on_left_button_release: Drag dx={dx}, dy={dy}. Threshold={drag_thresh}"
)
if dx < drag_thresh and dy < drag_thresh: # È un click
logger.debug(f"MCM _on_left_button_release: Detected as a click.")
if (
self._current_map_geo_bounds_gui is not None
and self._map_photo_image is not None
):
try:
map_pixel_shape = (
self._map_photo_image.width(),
self._map_photo_image.height(),
)
logger.debug(
f"MCM _on_left_button_release: Using map_pixel_shape={map_pixel_shape} and bounds={self._current_map_geo_bounds_gui} for geo conversion."
)
clicked_lon, clicked_lat = _pixel_to_geo(
event.x,
event.y,
self._current_map_geo_bounds_gui,
map_pixel_shape,
)
if clicked_lon is not None and clicked_lat is not None:
logger.info(
f"Map Left-Clicked at Geo ({clicked_lat:.5f}, {clicked_lon:.5f}) from Canvas ({event.x},{event.y})"
)
if self.app_controller and hasattr(
self.app_controller, "on_map_left_click"
):
self.app_controller.on_map_left_click(
clicked_lat, clicked_lon, event.x_root, event.y_root
)
else:
logger.warning(
f"Failed to convert left click pixel ({event.x},{event.y}) to geo. Current map bounds: {self._current_map_geo_bounds_gui}"
)
except Exception as e_click_convert:
logger.error(
f"Error during left click geo conversion: {e_click_convert}",
exc_info=True,
)
else:
logger.warning(
"Map context missing for left click geo conversion (_current_map_geo_bounds_gui or _map_photo_image is None)."
)
else: # È un drag
# La logica di panning con drag è più complessa, per ora gestiamo solo il click
logger.debug(
"MCM _on_left_button_release: Detected as a drag, not a click. (Drag Panning TBD)"
)
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
logger.debug(f"MCM _on_right_click: CanvasX={event.x}, CanvasY={event.y}")
if self._current_map_geo_bounds_gui is None or self._map_photo_image is None:
logger.warning("Map context missing for right click geo conversion.")
return
try:
map_pixel_shape = (
self._map_photo_image.width(),
self._map_photo_image.height(),
)
geo_lon, geo_lat = _pixel_to_geo(
event.x, event.y, self._current_map_geo_bounds_gui, map_pixel_shape
)
if geo_lon is not None and geo_lat is not None:
logger.info(f"Map Right-Clicked at Geo ({geo_lat:.5f}, {geo_lon:.5f})")
if self.app_controller and hasattr(
self.app_controller, "on_map_right_click"
): # Modificato in on_map_right_click
self.app_controller.on_map_right_click(
geo_lat, geo_lon, event.x_root, event.y_root
)
else:
logger.warning(
f"Failed to convert right click pixel ({event.x},{event.y}) to geo."
)
except Exception as e_rclick_convert:
logger.error(
f"Error during right click geo conversion: {e_rclick_convert}",
exc_info=True,
)
def update_flights_on_map(self, flight_states: List[CanonicalFlightState]):
logger.info(
f"MCM update_flights_on_map (GUI): Received {len(flight_states)} flight states."
)
with self._map_data_lock: # Protegge _current_flights_to_display_gui e flight_tracks_gui
self._current_flights_to_display_gui = (
flight_states # Sostituisce la lista dei voli
)
if not flight_states: # Monitoraggio fermato o nessun volo
logger.info(
"MCM update_flights_on_map: Received empty flight_states. Clearing all flight tracks."
)
self.flight_tracks_gui.clear()
else: # Ci sono voli, aggiorna le tracce
current_time = time.time()
active_icao_this_update = set()
for state in flight_states:
if (
state.latitude is not None
and state.longitude is not None
and state.timestamp is not None
):
active_icao_this_update.add(state.icao24)
if state.icao24 not in self.flight_tracks_gui:
# Usa self.max_track_points (che è letto da config) per il maxlen
self.flight_tracks_gui[state.icao24] = deque(
maxlen=self.max_track_points + 5
) # +5 per buffer
self.flight_tracks_gui[state.icao24].append(
(state.latitude, state.longitude, state.timestamp)
)
tracks_to_remove_gui = []
for icao, track_deque in self.flight_tracks_gui.items():
if (
not track_deque
): # Rimuovi se per qualche motivo la deque è vuota
tracks_to_remove_gui.append(icao)
continue
if (
icao not in active_icao_this_update
): # Volo non più attivo nell'update corrente
last_point_time = track_deque[-1][2]
if current_time - last_point_time > self.max_track_age_seconds:
tracks_to_remove_gui.append(
icao
) # Rimuovi se anche troppo vecchia
if tracks_to_remove_gui:
logger.debug(
f"MCM update_flights_on_map: Removing {len(tracks_to_remove_gui)} old/inactive GUI tracks."
)
for icao in tracks_to_remove_gui:
if icao in self.flight_tracks_gui:
del self.flight_tracks_gui[icao]
logger.debug(
f"MCM update_flights_on_map: Updated _current_flights_to_display_gui with {len(self._current_flights_to_display_gui)} states. Total tracks: {len(self.flight_tracks_gui)}."
)
# Richiedi un re-render della mappa con i nuovi dati dei voli/tracce
if (
self._current_center_lat_gui is not None
and self._current_center_lon_gui is not None
and self._current_zoom_gui is not None
):
logger.info(
"MCM update_flights_on_map: Requesting map re-render due to flight update."
)
self._request_map_render(
self._current_center_lat_gui,
self._current_center_lon_gui,
self._current_zoom_gui,
)
else:
logger.warning(
"MCM update_flights_on_map: Cannot request map render for flight update - current map view state not set."
)
def get_current_map_info(self) -> Dict[str, Any]:
logger.debug("MCM get_current_map_info (GUI) called.")
map_size_km_w, map_size_km_h = None, None
if (
PYPROJ_MODULE_LOCALLY_AVAILABLE
and pyproj
and self._current_map_geo_bounds_gui
):
try:
size_km_tuple = calculate_geographic_bbox_size_km(
self._current_map_geo_bounds_gui
)
if size_km_tuple:
map_size_km_w, map_size_km_h = size_km_tuple
except Exception as e_map_size_info:
logger.warning(
f"Error calculating current map geo size for info panel: {e_map_size_info}",
exc_info=False,
)
with self._map_data_lock: # Accedi a dati condivisi in modo sicuro
flight_count = len(self._current_flights_to_display_gui)
target_bbox_copy = (
copy.deepcopy(self._target_bbox_input_gui)
if self._target_bbox_input_gui
else None
)
info = {
"center_lat": self._current_center_lat_gui,
"center_lon": self._current_center_lon_gui,
"zoom": self._current_zoom_gui,
"map_geo_bounds": self._current_map_geo_bounds_gui,
"target_bbox_input": target_bbox_copy,
"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": flight_count,
}
logger.debug(
f"MCM get_current_map_info returning: Flights={info['flight_count']}, Zoom={info['zoom']}, Center=({info['center_lat']},{info['center_lon']})"
)
return info
def show_map_context_menu_from_gui(
self, latitude: float, longitude: float, screen_x: int, screen_y: int
):
logger.info(
f"MCM show_map_context_menu_from_gui: Lat {latitude:.4f}, Lon {longitude:.4f}"
)
if not self.canvas.winfo_exists():
logger.warning("MCM show_map_context_menu_from_gui: Canvas does not exist.")
return
root_widget = self.canvas.winfo_toplevel() # Prendi la finestra principale
try:
context_menu = tk.Menu(root_widget, tearoff=0)
decimals = getattr(map_constants, "COORDINATE_DECIMAL_PLACES", 5)
context_menu.add_command(
label=f"Context @ {latitude:.{decimals}f},{longitude:.{decimals}f}",
state=tk.DISABLED,
)
context_menu.add_separator()
if self.app_controller: # Verifica se il controller è impostato
if hasattr(self.app_controller, "recenter_map_at_coords"):
context_menu.add_command(
label="Center map here",
command=lambda: self.app_controller.recenter_map_at_coords(
latitude, longitude
),
)
if hasattr(self.app_controller, "set_bbox_around_coords"):
area_km_cfg_name = "DEFAULT_CLICK_AREA_SIZE_KM" # Nome della costante nel controller
area_km = getattr(
self.app_controller, area_km_cfg_name, 50.0
) # Usa getattr per fallback
context_menu.add_command(
label=f"Set {area_km:.0f}km Mon. Area",
command=lambda: self.app_controller.set_bbox_around_coords(
latitude, longitude, area_km
),
)
context_menu.tk_popup(screen_x, screen_y)
logger.debug("MCM show_map_context_menu_from_gui: Context menu popped up.")
except tk.TclError as e_menu_tcl_ctx:
logger.warning(
f"TclError showing MapCanvasManager context menu: {e_menu_tcl_ctx}."
)
except Exception as e_menu_ctx:
logger.error(
f"Error creating/showing MapCanvasManager context menu: {e_menu_ctx}",
exc_info=True,
)
def recenter_map_at_coords(self, lat: float, lon: float):
logger.info(
f"MCM recenter_map_at_coords (GUI): Request to recenter map @ Geo ({lat:.4f}, {lon:.4f})"
)
if self._current_zoom_gui is not None and self.canvas.winfo_exists():
self._current_center_lat_gui = lat
self._current_center_lon_gui = lon
logger.debug(
f"MCM recenter_map_at_coords: Updated GUI center to ({lat:.4f},{lon:.4f}). Requesting render."
)
self._request_map_render(lat, lon, self._current_zoom_gui)
else:
logger.warning(
"MCM recenter_map_at_coords: Cannot recenter map - missing current zoom or canvas not available."
)
def set_bbox_around_coords(
self, center_lat: float, center_lon: float, area_size_km: float
):
logger.info(
f"MCM set_bbox_around_coords (GUI): 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(
"MCM set_bbox_around_coords: Cannot set BBox - pyproj library not available."
)
if self.app_controller and hasattr(
self.app_controller, "show_error_message"
):
self.app_controller.show_error_message(
"Map Error", "Cannot calculate BBox: Geographic libraries missing."
)
return
try:
bbox_tuple_wesn = get_bounding_box_from_center_size(
center_lat, center_lon, area_size_km
) # W,S,E,N
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):
logger.debug(
f"MCM set_bbox_around_coords: Calculated valid BBox: {bbox_dict}. Calling set_target_bbox."
)
self.set_target_bbox(
bbox_dict
) # Questo chiamerà _request_map_render_for_bbox
else:
logger.error(
f"MCM set_bbox_around_coords: Calculated BBox 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"MCM set_bbox_around_coords: 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_set_bbox_calc:
logger.exception(
f"MCM set_bbox_around_coords: Unexpected error calculating BBox: {e_set_bbox_calc}"
)
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_set_bbox_calc}",
)
def zoom_in_at_center(self):
logger.debug(
f"MCM zoom_in_at_center (GUI) called. Current zoom: {self._current_zoom_gui}"
)
if (
self._current_zoom_gui is None
or self._current_center_lat_gui is None
or self._current_center_lon_gui is None
):
logger.warning(
"MCM zoom_in_at_center: Cannot zoom in - current map state (zoom/center) is not defined."
)
return
if not self.canvas.winfo_exists():
logger.warning(
"MCM zoom_in_at_center: Cannot zoom in - canvas not available."
)
return
max_zoom_svc = (
self.map_service.max_zoom
if self.map_service
else map_constants.DEFAULT_MAX_ZOOM_FALLBACK
)
new_zoom = min(self._current_zoom_gui + 1, max_zoom_svc)
logger.debug(
f"MCM zoom_in_at_center: new_zoom calculated as {new_zoom} (max_svc_zoom={max_zoom_svc})"
)
if new_zoom != self._current_zoom_gui:
logger.info(
f"MCM zoom_in_at_center: Zooming in from {self._current_zoom_gui} to {new_zoom}."
)
self._current_zoom_gui = new_zoom
self._request_map_render(
self._current_center_lat_gui, self._current_center_lon_gui, new_zoom
)
else:
logger.debug(
f"MCM zoom_in_at_center: Already at max zoom ({self._current_zoom_gui}). Cannot zoom in further."
)
def zoom_out_at_center(self):
logger.debug(
f"MCM zoom_out_at_center (GUI) called. Current zoom: {self._current_zoom_gui}"
)
if (
self._current_zoom_gui is None
or self._current_center_lat_gui is None
or self._current_center_lon_gui is None
):
logger.warning(
"MCM zoom_out_at_center: Cannot zoom out - current map state (zoom/center) is not defined."
)
return
if not self.canvas.winfo_exists():
logger.warning(
"MCM zoom_out_at_center: Cannot zoom out - canvas not available."
)
return
new_zoom = max(map_constants.MIN_ZOOM_LEVEL, self._current_zoom_gui - 1)
logger.debug(
f"MCM zoom_out_at_center: new_zoom calculated as {new_zoom} (min_level={map_constants.MIN_ZOOM_LEVEL})"
)
if new_zoom != self._current_zoom_gui:
logger.info(
f"MCM zoom_out_at_center: Zooming out from {self._current_zoom_gui} to {new_zoom}."
)
self._current_zoom_gui = new_zoom
self._request_map_render(
self._current_center_lat_gui, self._current_center_lon_gui, new_zoom
)
else:
logger.debug(
f"MCM zoom_out_at_center: Already at min zoom ({self._current_zoom_gui}). Cannot zoom out further."
)
def pan_map_fixed_step(
self, direction: str, step_fraction: float = PAN_STEP_FRACTION
):
logger.debug(
f"MCM pan_map_fixed_step (GUI) called. Direction: {direction}, Current center: ({self._current_center_lat_gui},{self._current_center_lon_gui}), Zoom: {self._current_zoom_gui}"
)
if (
self._current_center_lat_gui is None
or self._current_center_lon_gui is None
or self._current_zoom_gui is None
):
logger.warning(
"MCM pan_map_fixed_step: Cannot pan map - current map state (center/zoom) not fully defined."
)
return
if (
not self.canvas.winfo_exists()
or not PYPROJ_MODULE_LOCALLY_AVAILABLE
or pyproj is None
):
logger.warning(
"MCM pan_map_fixed_step: Cannot pan map - canvas or PyProj library 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)
logger.debug(
f"MCM pan_map_fixed_step: Pan step pixels W={pan_step_px_w}, H={pan_step_px_h}"
)
if direction == "left":
delta_x_pixels = (
-pan_step_px_w
) # Muove la mappa a sinistra, quindi il centro si sposta a destra (+lon)
elif direction == "right":
delta_x_pixels = pan_step_px_w # Muove la mappa a destra, centro si sposta a sinistra (-lon)
elif direction == "up":
delta_y_pixels = (
-pan_step_px_h
) # Muove mappa su, centro si sposta giù (-lat)
elif direction == "down":
delta_y_pixels = (
pan_step_px_h # Muove mappa giù, centro si sposta su (+lat)
)
else:
logger.warning(
f"MCM pan_map_fixed_step: Unknown pan direction: {direction}"
)
return
logger.debug(
f"MCM pan_map_fixed_step: Pixel deltas dx={delta_x_pixels}, dy={delta_y_pixels} (relative to current center)"
)
res_m_px = calculate_meters_per_pixel(
self._current_center_lat_gui,
self._current_zoom_gui,
self.tile_manager.tile_size,
)
if res_m_px is None or res_m_px <= 1e-9:
logger.error(
"MCM pan_map_fixed_step: Could not calculate valid resolution for panning. Cannot pan."
)
return
logger.debug(
f"MCM pan_map_fixed_step: Resolution for panning: {res_m_px:.2f} m/px"
)
delta_meters_x = delta_x_pixels * res_m_px # Delta in metri verso Est
delta_meters_y = (
-delta_y_pixels * res_m_px
) # Delta in metri verso Nord (NB: -dy perché pixel Y aumenta verso il basso)
logger.debug(
f"MCM pan_map_fixed_step: Meter deltas dMx={delta_meters_x:.2f} (East), dMy={delta_meters_y:.2f} (North)"
)
geod = pyproj.Geod(ellps="WGS84")
current_calc_lon, current_calc_lat = (
self._current_center_lon_gui,
self._current_center_lat_gui,
)
# Sposta prima in longitudine (Est/Ovest)
if abs(delta_meters_x) > 1e-9:
azimuth_lon = 90.0 if delta_meters_x > 0 else 270.0 # 90 Est, 270 Ovest
# pyproj.Geod.fwd aspetta la distanza positiva
new_lon, _, _ = geod.fwd(
current_calc_lon, current_calc_lat, azimuth_lon, abs(delta_meters_x)
)
current_calc_lon = new_lon
logger.debug(
f"MCM pan_map_fixed_step: After lon shift (az={azimuth_lon}, dist={abs(delta_meters_x):.1f}m), new lon={current_calc_lon:.4f}"
)
# Poi sposta in latitudine (Nord/Sud) partendo dalla nuova longitudine
if abs(delta_meters_y) > 1e-9:
azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0 # 0 Nord, 180 Sud
_, new_lat, _ = geod.fwd(
current_calc_lon, current_calc_lat, azimuth_lat, abs(delta_meters_y)
)
current_calc_lat = new_lat
logger.debug(
f"MCM pan_map_fixed_step: After lat shift (az={azimuth_lat}, dist={abs(delta_meters_y):.1f}m), new lat={current_calc_lat:.4f}"
)
MAX_MERCATOR_LAT = 85.05112878 # Limite per la proiezione Web Mercator
final_new_center_lat = max(
-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, current_calc_lat)
)
# Normalizza longitudine a [-180, 180]
final_new_center_lon = (current_calc_lon + 180) % 360 - 180
if (
final_new_center_lon == -180 and current_calc_lon > 0
): # Edge case for 180 meridian
final_new_center_lon = 180.0
logger.debug(
f"MCM pan_map_fixed_step: Clamped & Normalized new center: ({final_new_center_lat:.4f}, {final_new_center_lon:.4f})"
)
self._current_center_lat_gui = final_new_center_lat
self._current_center_lon_gui = final_new_center_lon
logger.info(
f"MCM pan_map_fixed_step: Panning map content '{direction}'. Updated GUI center to ({final_new_center_lat:.4f}, {final_new_center_lon:.4f}). Requesting render."
)
self._request_map_render(
final_new_center_lat, final_new_center_lon, self._current_zoom_gui
)
def center_map_and_fit_patch(
self, center_lat: float, center_lon: float, patch_size_km: float
):
logger.info(
f"MCM center_map_and_fit_patch (GUI): 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(
"MCM center_map_and_fit_patch: 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
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,
)
logger.debug(
f"MCM center_map_and_fit_patch: Zoom for width={zoom_for_width}, zoom for height={zoom_for_height}"
)
if zoom_for_width is None or zoom_for_height is None:
logger.error(
f"MCM center_map_and_fit_patch: Could not calculate zoom to fit patch of {patch_size_km}km. Using current or default zoom."
)
new_zoom = (
self._current_zoom_gui
if self._current_zoom_gui is not None
else map_constants.DEFAULT_INITIAL_ZOOM
)
else:
new_zoom = min(
zoom_for_width, zoom_for_height
) # Prendi il più "lontano" per farci stare tutto
max_zoom_limit_svc = (
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_svc))
logger.info(
f"MCM center_map_and_fit_patch: Calculated final zoom: {new_zoom}. Target center: ({center_lat:.4f}, {center_lon:.4f})"
)
self._current_center_lat_gui = center_lat
self._current_center_lon_gui = center_lon
self._current_zoom_gui = new_zoom
logger.debug(
f"MCM center_map_and_fit_patch: Updated GUI state. Requesting render."
)
self._request_map_render(center_lat, center_lon, new_zoom)
def shutdown_worker(self):
logger.info("MapCanvasManager: Shutdown worker requested.")
self._map_worker_stop_event.set() # Segnala al worker di fermarsi
# Cancella il processore dei risultati nella GUI
if self._gui_after_id_result_processor and self.canvas.winfo_exists():
try:
self.canvas.after_cancel(self._gui_after_id_result_processor)
logger.debug("MapCanvasManager: Cancelled GUI result processor task.")
except Exception as e_cancel_gui_proc:
logger.warning(
f"MapCanvasManager: Error cancelling GUI result processor: {e_cancel_gui_proc}"
)
self._gui_after_id_result_processor = None
# Invia un messaggio di shutdown speciale al worker per sbloccarlo se è in attesa sulla coda
try:
self._map_render_request_queue.put_nowait(
{"type": RENDER_REQUEST_TYPE_SHUTDOWN, "request_id": -999}
)
logger.debug("MapCanvasManager: Sent shutdown signal to worker queue.")
except queue.Full:
logger.warning(
"MapCanvasManager: Worker request queue full, cannot send shutdown signal via queue. Worker might take longer to stop."
)
except Exception as e_put_shutdown:
logger.error(
f"MapCanvasManager: Error sending shutdown signal to worker queue: {e_put_shutdown}"
)
if self._map_worker_thread and self._map_worker_thread.is_alive():
logger.info("MapCanvasManager: Waiting for map worker thread to join...")
self._map_worker_thread.join(
timeout=2.0
) # Aspetta per un massimo di 2 secondi
if self._map_worker_thread.is_alive():
logger.warning(
"MapCanvasManager: Map worker thread did not join in time."
)
else:
logger.info("MapCanvasManager: Map worker thread joined successfully.")
else:
logger.info(
"MapCanvasManager: Map worker thread was not running or already stopped."
)
self._map_worker_thread = None
# Pulisci le code
while not self._map_render_request_queue.empty():
try:
self._map_render_request_queue.get_nowait()
self._map_render_request_queue.task_done()
except Exception:
break
while not self._map_render_result_queue.empty():
try:
self._map_render_result_queue.get_nowait()
self._map_render_result_queue.task_done()
except Exception:
break
logger.info("MapCanvasManager: Worker shutdown sequence complete.")