diff --git a/flightmonitor/controller/app_controller.py b/flightmonitor/controller/app_controller.py index 95fc835..c0f5d01 100644 --- a/flightmonitor/controller/app_controller.py +++ b/flightmonitor/controller/app_controller.py @@ -716,34 +716,47 @@ class AppController: ) def on_application_exit(self): - module_logger.info( - "Controller: Application exit requested. Cleaning up resources." - ) - is_adapter_considered_running = ( - self.live_adapter_thread and self.live_adapter_thread.is_alive() - ) or self.is_live_monitoring_active - if is_adapter_considered_running: - module_logger.debug( - "Controller: Live monitoring/adapter active during app exit, stopping it." - ) - self.stop_live_monitoring(from_error=False) + module_logger.info("Controller: Application exit requested. Cleaning up resources.") + + # MODIFIED: Added shutdown for MapCanvasManager's worker thread. + # WHY: To ensure the map rendering thread is properly terminated before the application exits. + # WHERE: At the beginning of the application exit process. + # HOW: Checking for the existence of main_window, map_manager_instance, + # and the shutdown_worker method, then calling it within a try-except block. + if self.main_window and \ + hasattr(self.main_window, "map_manager_instance") and \ + self.main_window.map_manager_instance is not None: + map_manager = self.main_window.map_manager_instance + if hasattr(map_manager, "shutdown_worker") and callable(map_manager.shutdown_worker): + module_logger.debug("Controller: Requesting MapCanvasManager to shutdown its worker.") + try: + map_manager.shutdown_worker() + module_logger.info("Controller: MapCanvasManager worker shutdown requested.") + except Exception as e_map_shutdown: + module_logger.error( + f"Controller: Error during MapCanvasManager worker shutdown: {e_map_shutdown}", + exc_info=True + ) + else: + module_logger.debug("Controller: MapCanvasManager instance found, but no 'shutdown_worker' method.") else: - module_logger.debug( - "Controller: Live monitoring/adapter not active or already stopped during app exit." - ) + module_logger.debug("Controller: No MapCanvasManager instance found or main window not set; skipping map worker shutdown.") + + is_adapter_considered_running = (self.live_adapter_thread and self.live_adapter_thread.is_alive()) or self.is_live_monitoring_active + if is_adapter_considered_running: + module_logger.debug("Controller: Live monitoring/adapter active during app exit, stopping it.") + self.stop_live_monitoring(from_error=False) # This will join the adapter thread + else: + module_logger.debug("Controller: Live monitoring/adapter not active or already stopped during app exit.") if self.data_storage: - module_logger.debug( - "Controller: Closing DataStorage connection during app exit." - ) + module_logger.debug("Controller: Closing DataStorage connection during app exit.") try: self.data_storage.close_connection() except Exception as e_db_close: - module_logger.error( - f"Error closing DataStorage: {e_db_close}", exc_info=True - ) + module_logger.error(f"Error closing DataStorage: {e_db_close}", exc_info=True) finally: - self.data_storage = None + self.data_storage = None # Ensure it's cleared module_logger.info("Controller: Cleanup on application exit finished.") def start_history_monitoring(self): diff --git a/flightmonitor/gui/main_window.py b/flightmonitor/gui/main_window.py index 87bee59..68311ec 100644 --- a/flightmonitor/gui/main_window.py +++ b/flightmonitor/gui/main_window.py @@ -14,7 +14,7 @@ import logging # Import per i livelli di logging (es. logging.ERROR) # Relative imports from ..data import config as app_config # Standardized alias -from ..utils.logger import get_logger, setup_logging, shutdown_gui_logging +from ..utils.logger import get_logger, setup_logging, shutdown_logging_system from ..data.common_models import CanonicalFlightState # Importa le costanti di stato GUI dal modulo utils centralizzato @@ -823,14 +823,15 @@ class MainWindow: f"Error during controller.on_application_exit: {e}", exc_info=True, ) - - module_logger.info("Shutting down GUI logging.") + + module_logger.info("Shutting down logging system.") # Messaggio aggiornato try: - shutdown_gui_logging() # Assicurati che questa funzione sia robusta - except Exception as e: # Logga eventuali errori, ma non fermare la chiusura - module_logger.error( - f"Error during shutdown_gui_logging: {e}", exc_info=True - ) + # MODIFIED: Changed shutdown_gui_logging() to shutdown_logging_system() + # WHY: The function name was changed in the logger.py module. + # HOW: Updated the function call. + shutdown_logging_system() # Assicurati che questa funzione sia robusta + except Exception as e: + module_logger.error(f"Error during shutdown_logging_system: {e}", exc_info=True) if hasattr(self, "root") and self.root.winfo_exists(): try: diff --git a/flightmonitor/map/map_canvas_manager.py b/flightmonitor/map/map_canvas_manager.py index 5c61c74..4bf059e 100644 --- a/flightmonitor/map/map_canvas_manager.py +++ b/flightmonitor/map/map_canvas_manager.py @@ -2,48 +2,39 @@ import tkinter as tk import math -import time # Aggiunto per il calcolo dell'età della traccia +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 + 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." - ) + 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 = None # type: ignore PYPROJ_MODULE_LOCALLY_AVAILABLE = False import logging - - logging.warning( - "MapCanvasManager: 'pyproj' not found. Geographic calculations impaired." - ) + logging.warning("MapCanvasManager: 'pyproj' not found. Geographic calculations impaired.") try: import mercantile - MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError: - mercantile = None # type: ignore + mercantile = None # type: ignore MERCANTILE_MODULE_LOCALLY_AVAILABLE = False import logging - - logging.error( - "MapCanvasManager: 'mercantile' not found. Tile conversions fail, map unusable." - ) + logging.error("MapCanvasManager: 'mercantile' not found. Tile conversions fail, map unusable.") from . import map_constants @@ -51,44 +42,39 @@ from ..data import config as app_config from ..data.common_models import CanonicalFlightState from .map_services import BaseMapService, OpenStreetMapService -from .map_tile_manager import ( - MapTileManager, -) # MODIFIED: Changed from map_manager to map_tile_manager +from .map_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, - deg_to_dms_string, _is_valid_bbox_dict, _pixel_to_geo, calculate_meters_per_pixel, calculate_zoom_level_for_geographic_size, - get_bounding_box_from_center_size, + get_bounding_box_from_center_size ) from . import map_drawing try: from ..utils.logger import get_logger - logger = get_logger(__name__) except ImportError: import logging - logger = logging.getLogger(__name__) logger.warning("MapCanvasManager using fallback standard Python logger.") -CANVAS_SIZE_HARD_FALLBACK_PX = getattr(app_config, "DEFAULT_CANVAS_WIDTH", 800) -# MODIFIED: Referenced map_constants for MAP_TILE_CACHE_DIR -# WHY: Ensure consistency with where MAP_TILE_CACHE_DIR is defined (expected in map_constants or app_config) -# HOW: Changed app_config to map_constants. Assuming it's defined there. If it's in app_config, this should revert. -MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr( - map_constants, "MAP_TILE_CACHE_DIR", "flightmonitor_tile_cache_fallback" -) -RESIZE_DEBOUNCE_DELAY_MS = 150 +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 = 20 DEFAULT_MAX_TRACK_AGE_SECONDS = 300 +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: @@ -99,107 +85,483 @@ class MapCanvasManager: tk_canvas: tk.Canvas, initial_bbox_dict: Dict[str, float], ): - logger.info("Initializing MapCanvasManager...") - if ( - not PIL_IMAGE_LIB_AVAILABLE - or not MERCANTILE_MODULE_LOCALLY_AVAILABLE - or mercantile is None - ): + # MODIFIED: Aggiorniamo il nome del DEBUG STEP + logger.info(">>> MapCanvasManager __init__ STARTING (DEBUG STEP 3.3 - Initial Render Request) <<<") + + 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 + 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 + if self.canvas_width <= 1: self.canvas_width = CANVAS_SIZE_HARD_FALLBACK_PX self.canvas_height = self.canvas.winfo_height() - if self.canvas_height <= 1: - self.canvas_height = getattr(app_config, "DEFAULT_CANVAS_HEIGHT", 600) + if self.canvas_height <= 1: self.canvas_height = getattr(app_config, 'DEFAULT_CANVAS_HEIGHT', 600) + logger.info(f"MCM __init__ (DEBUG STEP 3.3): Canvas dims {self.canvas_width}x{self.canvas_height}") - if self.canvas_width <= 0 or self.canvas_height <= 0: - logger.critical( - f"MapCanvasManager init with invalid canvas dims ({self.canvas_width}x{self.canvas_height}) after fallbacks." - ) - raise ValueError("Invalid canvas dimensions for MapCanvasManager.") - - self._current_center_lat: Optional[float] = None - self._current_center_lon: Optional[float] = None - self._current_zoom: int = map_constants.DEFAULT_INITIAL_ZOOM - self._current_map_geo_bounds: Optional[Tuple[float, float, float, float]] = None - - self._map_pil_image: Optional[Image.Image] = None + # --- Inizializzazione Attributi (come nel Passo precedente) --- + 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, tile_pixel_size=self.map_service.tile_size, ) - logger.info( - f"MapTileManager initialized for service '{self.tile_manager.service_identifier_name}'." - ) - - self._target_bbox_input: Optional[Dict[str, float]] = None - self._current_flights_to_display: List[CanonicalFlightState] = [] - - self.flight_tracks: Dict[str, deque[Tuple[float, float, float]]] = {} + logger.info(f"MCM __init__ (DEBUG STEP 3.3): MapTileManager initialized.") + self._current_flights_to_display_gui: List[CanonicalFlightState] = [] + self.flight_tracks_gui: Dict[str, deque[Tuple[float, float, float]]] = {} self.max_track_points: int = DEFAULT_MAX_TRACK_POINTS self.max_track_age_seconds: float = DEFAULT_MAX_TRACK_AGE_SECONDS - self._resize_debounce_job_id: Optional[str] = None + 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() + logger.info("MCM __init__ (DEBUG STEP 3.3): All attributes initialized.") + # --- Fine Inizializzazione Attributi --- + logger.info("MCM __init__ (DEBUG STEP 3.3): Attempting to start map worker thread...") + self._start_map_worker_thread() # WORKER AVVIATO + + # >> DECOMMENTIAMO LA LOGICA PER LA RICHIESTA DELLA MAPPA INIZIALE << + logger.info(f"MCM __init__ (DEBUG STEP 3.3): Processing initial_bbox_dict: {initial_bbox_dict}") if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict): - self._target_bbox_input = initial_bbox_dict.copy() - self.update_map_view_for_bbox( - initial_bbox_dict, preserve_current_zoom_if_possible=False - ) + self._target_bbox_input_gui = initial_bbox_dict.copy() + logger.info(f"MCM __init__ (DEBUG STEP 3.3): 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"Invalid initial_bbox_dict: {initial_bbox_dict}. Using default fallback view." - ) - default_bbox_cfg = { - "lat_min": app_config.DEFAULT_BBOX_LAT_MIN, - "lon_min": app_config.DEFAULT_BBOX_LON_MIN, - "lat_max": app_config.DEFAULT_BBOX_LAT_MAX, - "lon_max": app_config.DEFAULT_BBOX_LON_MAX, - } - if _is_valid_bbox_dict(default_bbox_cfg): - self._target_bbox_input = default_bbox_cfg.copy() - self.set_target_bbox(default_bbox_cfg) - else: - logger.critical( - f"Default fallback BBox from config is invalid: {default_bbox_cfg}. Cannot initialize map view." - ) - self._target_bbox_input = None - self._current_center_lat = getattr( - app_config, "DEFAULT_MAP_CENTER_LAT", 45.0 - ) - self._current_center_lon = getattr( - app_config, "DEFAULT_MAP_CENTER_LON", 9.0 - ) - self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM - self.recenter_and_redraw( - self._current_center_lat, - self._current_center_lon, - self._current_zoom, - ) + logger.warning(f"MCM __init__ (DEBUG STEP 3.3): 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__ (DEBUG STEP 3.3): 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: # Fallback critico + logger.critical(f"MCM __init__ (DEBUG STEP 3.3): 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__ (DEBUG STEP 3.3): 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) + # >> FINE DECOMMENTO LOGICA RICHIESTA MAPPA INIZIALE << self._setup_event_bindings() - logger.info( - f"MapCanvasManager initialized for canvas size {self.canvas_width}x{self.canvas_height}." + logger.info("MCM __init__ (DEBUG STEP 3.3): Event bindings set up.") + + logger.info("MCM __init__ (DEBUG STEP 3.3): Attempting to start GUI result processing...") + self._start_gui_result_processing() # PROCESSORE DEI RISULTATI AVVIATO + + # Il placeholder verrà sovrascritto dal messaggio "Loading Map..." di _request_map_render + # if self.canvas.winfo_exists(): + # self.canvas.after(100, lambda: self._display_placeholder_text("Map Initial Request Sent (DEBUG STEP 3.3)")) + + logger.info(">>> MapCanvasManager __init__ FINISHED (DEBUG STEP 3.3) <<<") + + + def _start_map_worker_thread(self): + # ... (come prima) + 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): + # MODIFIED: Ripristinata la logica di rendering completa + 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 (FULL RENDER ENABLED).") + + 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} (FULL RENDER)") + 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 = request_data.get("flights", []) + tracks_to_draw = request_data.get("tracks", {}) + max_track_pts = request_data.get("max_track_points", DEFAULT_MAX_TRACK_POINTS) + + # >> ESEGUI IL RENDERING EFFETTIVO << + 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 + ) + + 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 # Esce dal while loop se lo stop è stato segnalato + + 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: # Ensure task_done called if we successfully got an item + try: self._map_render_request_queue.task_done() + except ValueError: pass # if task_done called too many times + time.sleep(0.5) # Avoid rapid spin on continuous error + logger.info(f"{thread_name}: Worker thread target loop finished (FULL RENDER ENABLED).") + + # ... (tutti gli altri metodi di MapCanvasManager come nella versione completa precedente + # con i log dettagliati che ti avevo fornito, inclusi _execute_render_pipeline, + # _process_map_render_results, _request_map_render, _setup_event_bindings, + # _on_canvas_resize, _perform_resize_redraw, set_max_track_points, + # set_target_bbox, _request_map_render_for_bbox, _clear_canvas_display_elements, + # _display_placeholder_text, clear_map_display, _on_left_button_press, + # _on_left_button_release, _on_right_click, update_flights_on_map, + # get_current_map_info, show_map_context_menu_from_gui, recenter_map_at_coords, + # set_bbox_around_coords, zoom_in_at_center, zoom_out_at_center, + # pan_map_fixed_step, center_map_and_fit_patch, shutdown_worker, + # _start_gui_result_processing ) + # + # La cosa importante è che _start_gui_result_processing() e la logica + # per la richiesta iniziale in __init__ sono ancora commentate. + # Il metodo _execute_render_pipeline NON viene chiamato da questo worker. + + # COPIA INCOLLA IL RESTO DEI METODI DA QUI IN POI dalla versione precedente + # che ti ho fornito con tutti i log (quella che iniziava con "Certamente. Ecco il codice completo...") + # È importante avere tutti i metodi definiti, anche se non tutti vengono chiamati in questo specifico test. + def _start_gui_result_processing(self): # DEVE ESSERE PRESENTE ANCHE SE NON CHIAMATO DA __INIT__ + 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], + max_track_points: int + ) -> 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: ", "")) + # Make sure ImageTk is available before calling it + 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 + 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( + 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) + + for icao, track_deque in flight_tracks.items(): + if len(track_deque) < 2: continue + pixel_points_for_track: List[Tuple[int, int]] = [] + for lat_t, lon_t, _ts_t in track_deque: + pixel_coords_t = map_drawing._geo_to_pixel_on_unscaled_map( + lat_t, lon_t, actual_stitched_map_geo_bounds, img_shape + ) + if pixel_coords_t: pixel_points_for_track.append(pixel_coords_t) + if len(pixel_points_for_track) >= 2: + try: draw.line(pixel_points_for_track, fill="orange", width=1) + except Exception as e_track_draw_pipe: logger.error(f"WorkerPipeline: Error drawing track for {icao}: {e_track_draw_pipe}", exc_info=False) + logger.debug(f"WorkerPipeline: Drew {len(flight_tracks)} tracks (approx).") + + + 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) + 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( + flight.latitude, flight.longitude, actual_stitched_map_geo_bounds, img_shape + ) + if pixel_coords_f: + try: + map_drawing._draw_single_flight(draw, pixel_coords_f, flight, label_font, flight_base_color_str="red") + 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.") + + try: + if ImageTk: # Ensure ImageTk is available + 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}.") + + 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) + 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}") + + 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: + self._expected_render_id_gui = current_request_id -1 + logger.debug(f"GUI _request_map_render: Reverted _expected_render_id_gui to {self._expected_render_id_gui} due to queue full.") + 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("", self._on_canvas_resize) @@ -209,987 +571,434 @@ class MapCanvasManager: 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 - if ( - new_width > 1 - and new_height > 1 - and (self.canvas_width != new_width or self.canvas_height != new_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: - self.canvas.after_cancel(self._resize_debounce_job_id) - except Exception: - pass - finally: - self._resize_debounce_job_id = None - self._resize_debounce_job_id = self.canvas.after( - RESIZE_DEBOUNCE_DELAY_MS, - self._perform_resize_redraw, - new_width, - new_height, - ) + 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"Performing debounced resize redraw for dimensions {width}x{height}." - ) + 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 - if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input): - logger.debug("Refitting map view to target BBox after resize.") - self.update_map_view_for_bbox( - self._target_bbox_input, preserve_current_zoom_if_possible=True - ) - elif ( - self._current_center_lat is not None - and self._current_center_lon is not None - and self._current_zoom is not None - ): - logger.debug("Recentering map view at current geo center after resize.") - self.recenter_and_redraw( - self._current_center_lat, self._current_center_lon, self._current_zoom + 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( - "No valid BBox or geo center after resize. Cannot redraw effectively." - ) + 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}" - ) + logger.info(f"MapCanvasManager: Max track points updating from {self.max_track_points} to {new_length}") self.max_track_points = new_length - - for icao in list(self.flight_tracks.keys()): - track_deque = self.flight_tracks.get(icao) + 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()): + track_deque = self.flight_tracks_gui.get(icao) if track_deque: + trimmed_count = 0 while len(track_deque) > self.max_track_points: - track_deque.popleft() + track_deque.popleft(); trimmed_count+=1 + if trimmed_count > 0: logger.debug(f"Trimmed {trimmed_count} points from track {icao}. New len: {len(track_deque)}") if not track_deque: - if icao in self.flight_tracks: - del self.flight_tracks[icao] - - if self.canvas.winfo_exists() and self._map_pil_image: - logger.debug("Forcing map redraw after track length change.") - self._redraw_canvas_content() - else: - logger.debug( - f"MapCanvasManager: Max track points already set to {new_length}. No change." - ) + if icao in self.flight_tracks_gui: + del self.flight_tracks_gui[icao] + logger.debug(f"Removed empty track for {icao} after trimming.") + 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"MapCanvasManager: New target BBox requested: {new_bbox_dict}") + 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): - self._target_bbox_input = new_bbox_dict.copy() - self.update_map_view_for_bbox( - self._target_bbox_input, preserve_current_zoom_if_possible=False - ) - if self.app_controller and hasattr( - self.app_controller, "update_bbox_gui_fields" - ): - self.app_controller.update_bbox_gui_fields(self._target_bbox_input) + 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"Invalid/empty new_bbox_dict provided: {new_bbox_dict}. Clearing target BBox." - ) - self._target_bbox_input = None - if ( - self._current_center_lat is not None - and self._current_center_lon is not None - and self._current_zoom is not None - ): - self.recenter_and_redraw( - self._current_center_lat, - self._current_center_lon, - self._current_zoom, - ) + logger.warning(f"MCM set_target_bbox: Invalid/empty new_bbox_dict: {new_bbox_dict}. Clearing _target_bbox_input_gui.") + 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: - self.clear_map_display() - if self.app_controller and hasattr( - self.app_controller, "update_bbox_gui_fields" - ): - self.app_controller.update_bbox_gui_fields({}) + logger.warning("MCM set_target_bbox: Target BBox cleared, but no current view to re-render. Clearing map display.") + self.clear_map_display() + if self.app_controller and hasattr(self.app_controller, "update_bbox_gui_fields"): + self.app_controller.update_bbox_gui_fields({}) - def update_map_view_for_bbox( - self, - target_bbox_dict: Dict[str, float], - preserve_current_zoom_if_possible: bool = False, - ): + 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( - "update_map_view_for_bbox called with invalid/no target BBox." - ) - return - lat_min, lon_min, lat_max, lon_max = ( - target_bbox_dict["lat_min"], - target_bbox_dict["lon_min"], - target_bbox_dict["lat_max"], - target_bbox_dict["lon_max"], - ) - view_center_lat, view_center_lon = (lat_min + lat_max) / 2.0, ( - lon_min + lon_max - ) / 2.0 + 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: view_center_lon = (lon_min + (lon_max + 360.0)) / 2.0 - if view_center_lon >= 180.0: - view_center_lon -= 360.0 - - zoom_to_use = self._current_zoom + if 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 + 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) ) or (None, None) - - if ( - patch_width_km - and patch_height_km - and self.canvas_width > 0 - and self.canvas_height > 0 - ): - zoom_w = calculate_zoom_level_for_geographic_size( - view_center_lat, - patch_width_km * 1000, - self.canvas_width, - self.tile_manager.tile_size, - ) - zoom_h = calculate_zoom_level_for_geographic_size( - view_center_lat, - patch_height_km * 1000, - self.canvas_height, - self.tile_manager.tile_size, - ) - if zoom_w is not None and zoom_h is not None: - zoom_to_use = min(zoom_w, zoom_h) - elif zoom_w is not None: - zoom_to_use = zoom_w - elif zoom_h is not None: - zoom_to_use = zoom_h + 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: - zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM - logger.info( - f"Calculated zoom to fit BBox: {zoom_to_use} (based on W:{zoom_w}, H:{zoom_h})" - ) + 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( - "Could not calculate dimensions or canvas size for BBox zoom. Using default." - ) + 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 - max_zoom = ( - self.map_service.max_zoom - if self.map_service - else map_constants.DEFAULT_MAX_ZOOM_FALLBACK - ) - zoom_to_use = max(map_constants.MIN_ZOOM_LEVEL, min(zoom_to_use, max_zoom)) - self.recenter_and_redraw( - view_center_lat, - view_center_lon, - zoom_to_use, - ensure_bbox_is_covered_dict=target_bbox_dict, - ) + 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 recenter_and_redraw( - self, - center_lat: float, - center_lon: float, - zoom_level: int, - ensure_bbox_is_covered_dict: Optional[Dict[str, float]] = None, - ): - logger.info( - f"Recentering map. Center: ({center_lat:.4f}, {center_lon:.4f}), Zoom: {zoom_level}. Ensure BBox Covered: {'Yes' if ensure_bbox_is_covered_dict else 'No'}" - ) - if ( - not PIL_IMAGE_LIB_AVAILABLE - or not MERCANTILE_MODULE_LOCALLY_AVAILABLE - or mercantile is None - ): - logger.error("Pillow or Mercantile not available. Cannot recenter/redraw.") - self._clear_canvas_display() - return - self._current_center_lat, self._current_center_lon = center_lat, center_lon - max_zoom = ( - self.map_service.max_zoom - if self.map_service - else map_constants.DEFAULT_MAX_ZOOM_FALLBACK - ) - self._current_zoom = max( - map_constants.MIN_ZOOM_LEVEL, min(zoom_level, max_zoom) - ) - - if self.canvas_width <= 0 or self.canvas_height <= 0: - logger.error( - f"Canvas dims invalid ({self.canvas_width}x{self.canvas_height}). Cannot redraw." - ) - self._clear_canvas_display() - return - - canvas_geo_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom( - self._current_center_lat, - self._current_center_lon, - self.canvas_width, - self.canvas_height, - self._current_zoom, - self.tile_manager.tile_size, - ) - if not canvas_geo_bbox: - logger.error( - "Failed to calculate canvas geographic BBox. Cannot fetch tiles." - ) - self._clear_canvas_display() - return - - fetch_bounds_for_tiles = canvas_geo_bbox - if ensure_bbox_is_covered_dict and _is_valid_bbox_dict( - ensure_bbox_is_covered_dict - ): - logger.debug( - f"Ensuring BBox {ensure_bbox_is_covered_dict} is covered. Current canvas geo BBox: {canvas_geo_bbox}" - ) - - tile_xy_ranges = get_tile_ranges_for_bbox( - fetch_bounds_for_tiles, self._current_zoom - ) - if not tile_xy_ranges: - logger.error( - f"Failed to get tile ranges for {fetch_bounds_for_tiles} at zoom {self._current_zoom}. Cannot draw." - ) - self._clear_canvas_display() - if ( - PIL_IMAGE_LIB_AVAILABLE - and Image is not None - and ImageDraw is not None - and self.canvas.winfo_exists() - ): - try: - placeholder_img = Image.new( - "RGB", - (self.canvas_width, self.canvas_height), - map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB, - ) - draw = ImageDraw.Draw(placeholder_img) - map_drawing._draw_text_on_placeholder( - draw, placeholder_img.size, "Map Error\nCannot get tiles." - ) - self._map_photo_image = ImageTk.PhotoImage(placeholder_img) # type: ignore - if self.canvas.winfo_exists(): - self._canvas_image_id = self.canvas.create_image( - 0, 0, anchor=tk.NW, image=self._map_photo_image - ) - except Exception as e: - logger.error( - f"Failed to draw tile range error placeholder: {e}", - exc_info=True, - ) - return - - stitched_map_pil = self.tile_manager.stitch_map_image( - self._current_zoom, tile_xy_ranges[0], tile_xy_ranges[1] - ) - if not stitched_map_pil: - logger.error("Failed to stitch map image.") - self._clear_canvas_display() - return - - self._current_map_geo_bounds = self.tile_manager._get_bounds_for_tile_range( - self._current_zoom, tile_xy_ranges - ) - if not self._current_map_geo_bounds: - logger.warning( - "Could not determine actual stitched map geo bounds. Using fetch bounds as fallback." - ) - self._current_map_geo_bounds = fetch_bounds_for_tiles - - self._map_pil_image = stitched_map_pil - if self._map_pil_image and PIL_IMAGE_LIB_AVAILABLE: - self._redraw_canvas_content() - if self.app_controller and hasattr( - self.app_controller, "update_general_map_info" - ): - self.app_controller.update_general_map_info() - - def _redraw_canvas_content(self): - logger.debug("MapCanvasManager: _redraw_canvas_content called.") - if not PIL_IMAGE_LIB_AVAILABLE or Image is None or ImageDraw is None: - logger.warning( - "_redraw_canvas_content: Pillow/ImageDraw not available. Cannot draw." - ) - self._clear_canvas_display() - return - - self._clear_canvas_display() - if self._map_pil_image is None or self._current_map_geo_bounds is None: - logger.warning( - "No base map image or geo bounds. Canvas cleared, skipping overlay drawing." - ) - return - - if self._map_pil_image.mode != "RGBA": - base_map_rgba = self._map_pil_image.convert("RGBA") - image_to_draw_on = base_map_rgba.copy() - else: - image_to_draw_on = self._map_pil_image.copy() - - img_shape = image_to_draw_on.size - draw = ImageDraw.Draw(image_to_draw_on) - - # A. Disegna il BBox target (se definito) - if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input): - logger.debug(f"Drawing target BBox on map: {self._target_bbox_input}") - bbox_wesn = ( - self._target_bbox_input["lon_min"], - self._target_bbox_input["lat_min"], - self._target_bbox_input["lon_max"], - self._target_bbox_input["lat_max"], - ) - try: - # MODIFIED: Pass image_to_draw_on (PIL.Image.Image) instead of draw (PIL.ImageDraw.ImageDraw) - # The function map_drawing.draw_area_bounding_box expects an Image object - # and creates its own ImageDraw instance internally. - map_drawing.draw_area_bounding_box( - image_to_draw_on, - bbox_wesn, - self._current_map_geo_bounds, - img_shape, - color=map_constants.AREA_BOUNDARY_COLOR, - thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX, - ) - except Exception as e: - logger.error(f"Error drawing target BBox: {e}", exc_info=True) - - # B. Disegna le Tracce degli Aerei - for icao, track_deque in self.flight_tracks.items(): - if len(track_deque) < 2: - continue - - pixel_points_for_track: List[Tuple[int, int]] = [] - for lat, lon, _ts in track_deque: - pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map( - lat, lon, self._current_map_geo_bounds, img_shape - ) - if pixel_coords: - pixel_points_for_track.append(pixel_coords) - - if len(pixel_points_for_track) >= 2: - try: - track_color_str = "orange" - track_width = 1 - draw.line( - pixel_points_for_track, fill=track_color_str, width=track_width - ) - except Exception as e_track: - logger.error( - f"Error drawing track for ICAO {icao}: {e_track}", - exc_info=False, - ) - - # C. Disegna i Marker degli Aerei - flights_drawn_count = 0 - if ( - self._current_flights_to_display - and ImageDraw is not None - and map_drawing is not None - ): - font_size = map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE - if self._current_zoom is not None: - font_size += self._current_zoom - map_constants.DEM_TILE_LABEL_BASE_ZOOM - label_font = map_drawing._load_label_font(font_size) - - for flight in self._current_flights_to_display: - if flight.latitude is not None and flight.longitude is not None: - pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map( - flight.latitude, - flight.longitude, - self._current_map_geo_bounds, - img_shape, - ) - if pixel_coords: - try: - flight_marker_color = "red" - map_drawing._draw_single_flight( - draw, - pixel_coords, - flight, - label_font, - flight_base_color_str=flight_marker_color, - ) - flights_drawn_count += 1 - except Exception as e_flight: - logger.error( - f"Error drawing flight {flight.icao24}: {e_flight}", - exc_info=False, - ) - logger.debug( - f"Drew {flights_drawn_count} of {len(self._current_flights_to_display)} flight markers." - ) - elif self._current_flights_to_display: - logger.warning( - "ImageDraw or map_drawing module not available, skipping drawing flights." - ) - - # D. Crea PhotoImage e aggiorna il canvas - try: - if ImageTk: - self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on) - else: - logger.error("Pillow ImageTk missing. Cannot create PhotoImage.") - self._clear_canvas_display() - return - except Exception as e: - logger.error(f"Failed to create PhotoImage: {e}", exc_info=True) - self._clear_canvas_display() - return - - if self.canvas.winfo_exists(): - try: - self._canvas_image_id = self.canvas.create_image( - 0, 0, anchor=tk.NW, image=self._map_photo_image - ) - except tk.TclError as e: - logger.warning( - f"TclError drawing canvas image: {e}. GUI likely gone.", - exc_info=False, - ) - self._canvas_image_id = None - except Exception as e: - logger.error( - f"Unexpected error drawing canvas image: {e}", exc_info=True - ) - self._canvas_image_id = None - else: - logger.debug("_redraw_canvas_content: Canvas does not exist.") - - def _clear_canvas_display(self): + 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: - pass - finally: - self._canvas_image_id = None - self._map_photo_image = None + 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 + 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() + 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 + if canvas_h_disp <= 1 : canvas_h_disp = self.canvas_height + 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) + 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( - "MapCanvasManager: Clearing all map content and resetting view state." - ) - self._clear_canvas_display() - self._map_pil_image = None - self._current_flights_to_display = [] - self.flight_tracks.clear() - self._current_map_geo_bounds = None - self._target_bbox_input = None - if self.canvas.winfo_exists(): - try: - self.canvas.delete("placeholder_text") - except Exception: - pass - if self.app_controller and hasattr( - self.app_controller, "update_general_map_info" - ): + 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") + self._current_flights_to_display_gui = [] + self.flight_tracks_gui.clear() + self._current_map_geo_bounds_gui = None + logger.debug("MCM clear_map_display: Internal state cleared (flights, tracks, map_bounds).") + if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): + logger.debug("MCM clear_map_display: Requesting controller to 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 + 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() or not self._is_left_button_pressed: - self._is_left_button_pressed = False + 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._current_map_geo_bounds is not None and self._map_pil_image is not None: - map_pixel_shape = self._map_pil_image.size - clicked_lon, clicked_lat = _pixel_to_geo( - event.x, event.y, self._current_map_geo_bounds, map_pixel_shape - ) - if clicked_lon is not None and clicked_lat is not None: - logger.debug( - f"Map Left-Clicked at Geo ({clicked_lat:.5f}, {clicked_lon:.5f}) - Canvas ({event.x},{event.y})" - ) - if self.app_controller and hasattr( - self.app_controller, "on_map_left_click" - ): + 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: + 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: - self.app_controller.on_map_left_click( - clicked_lat, clicked_lon, event.x_root, event.y_root - ) - except Exception as e: - logger.error( - f"Error calling controller left click handler: {e}", - exc_info=False, - ) - else: - logger.warning( - f"Failed to convert left click pixel ({event.x},{event.y}) to geo." - ) - else: - logger.warning("Map context missing for left click geo conversion.") + 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: logger.debug("MCM _on_left_button_release: Detected as a drag, not a click.") self._drag_start_x_canvas, self._drag_start_y_canvas = None, None def _on_right_click(self, event: tk.Event): - if not self.canvas.winfo_exists(): - return - if self._current_map_geo_bounds is None or self._map_pil_image is None: - logger.warning("Map context missing for right click geo conversion.") - return - map_pixel_shape = self._map_pil_image.size - geo_lon, geo_lat = _pixel_to_geo( - event.x, event.y, self._current_map_geo_bounds, map_pixel_shape - ) - if geo_lon is not None and geo_lat is not None: - logger.info(f"Map Right-Clicked at Geo ({geo_lat:.5f}, {geo_lon:.5f})") - if self.app_controller and hasattr( - self.app_controller, "on_map_right_click" - ): - try: - self.app_controller.on_map_right_click( - geo_lat, geo_lon, event.x_root, event.y_root - ) - except Exception as e: - logger.error( - f"Error calling controller right click handler: {e}", - exc_info=False, - ) - else: - logger.warning( - f"Failed to convert right click pixel ({event.x},{event.y}) to geo." - ) + 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"): + 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.debug( - f"MapCanvasManager: Update flights received with {len(flight_states)} states. Current max_track_points: {self.max_track_points}" - ) - + logger.info(f"MCM update_flights_on_map (GUI): Received {len(flight_states)} flight states.") 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 - ): + if state.latitude is not None and state.longitude is not None and state.timestamp is not None: active_icao_this_update.add(state.icao24) - if state.icao24 not in self.flight_tracks: - self.flight_tracks[state.icao24] = deque() - - self.flight_tracks[state.icao24].append( - (state.latitude, state.longitude, state.timestamp) - ) - - while len(self.flight_tracks[state.icao24]) > self.max_track_points: - self.flight_tracks[state.icao24].popleft() - else: - logger.debug( - f"Skipping flight state for track update due to missing geo/ts: {state.icao24}" - ) - - tracks_to_remove = [] - for icao, track_deque in self.flight_tracks.items(): - if not track_deque: - tracks_to_remove.append(icao) - continue - + if state.icao24 not in self.flight_tracks_gui: + self.flight_tracks_gui[state.icao24] = deque(maxlen=self.max_track_points + 5) + 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: tracks_to_remove_gui.append(icao); continue last_point_time = track_deque[-1][2] - is_inactive_in_current_update = icao not in active_icao_this_update - is_track_too_old = ( - current_time - last_point_time > self.max_track_age_seconds - ) - - if is_inactive_in_current_update and is_track_too_old: - logger.debug( - f"Removing old and inactive track for {icao}. Last seen: {current_time - last_point_time:.0f}s ago." - ) - tracks_to_remove.append(icao) - elif not flight_states and is_track_too_old: - logger.debug( - f"Removing old track for {icao} (no active flights). Last seen: {current_time - last_point_time:.0f}s ago." - ) - tracks_to_remove.append(icao) - - for icao in tracks_to_remove: - if icao in self.flight_tracks: - del self.flight_tracks[icao] - - self._current_flights_to_display = flight_states - - if self.canvas.winfo_exists() and self._map_pil_image: - self._redraw_canvas_content() - elif not self._map_pil_image: - logger.debug( - "No base map image, flights/tracks will be drawn on next full redraw." - ) + is_inactive = icao not in active_icao_this_update + is_too_old = (current_time - last_point_time > self.max_track_age_seconds) + if is_inactive and is_too_old : tracks_to_remove_gui.append(icao) + elif not flight_states and is_too_old : tracks_to_remove_gui.append(icao) + 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] + self._current_flights_to_display_gui = flight_states + logger.debug(f"MCM update_flights_on_map: Updated _current_flights_to_display_gui with {len(self._current_flights_to_display_gui)} states.") + 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: + 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 - ) - if size_km_tuple: - map_size_km_w, map_size_km_h = size_km_tuple - except Exception as e: - logger.error(f"Error calc current map geo size: {e}", exc_info=False) - return { - "center_lat": self._current_center_lat, - "center_lon": self._current_center_lon, - "zoom": self._current_zoom, - "map_geo_bounds": self._current_map_geo_bounds, - "target_bbox_input": self._target_bbox_input, - "canvas_width": self.canvas_width, - "canvas_height": self.canvas_height, - "map_size_km_w": map_size_km_w, - "map_size_km_h": map_size_km_h, - "flight_count": len(self._current_flights_to_display), + 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) + 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": self._target_bbox_input_gui, + "canvas_width": self.canvas_width, "canvas_height": self.canvas_height, + "map_size_km_w": map_size_km_w, "map_size_km_h": map_size_km_h, + "flight_count": len(self._current_flights_to_display_gui) } + 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"MapCanvasManager: Showing context menu for click @ Geo ({latitude:.4f}, {longitude:.4f})" - ) + 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() try: context_menu = tk.Menu(root_widget, tearoff=0) - decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5) - context_menu.add_command( - label=f"Context @ {latitude:.{decimals}f},{longitude:.{decimals}f}", - state=tk.DISABLED, - ) + decimals = getattr(app_config, 'COORDINATE_DECIMAL_PLACES', 5) + context_menu.add_command(label=f"Context @ {latitude:.{decimals}f},{longitude:.{decimals}f}", state=tk.DISABLED) context_menu.add_separator() if self.app_controller: if hasattr(self.app_controller, "recenter_map_at_coords"): - context_menu.add_command( - label="Center map here", - command=lambda: self.app_controller.recenter_map_at_coords( - latitude, longitude - ), - ) + context_menu.add_command(label="Center map here", command=lambda: self.app_controller.recenter_map_at_coords(latitude, longitude)) if hasattr(self.app_controller, "set_bbox_around_coords"): area_km_cfg_name = "DEFAULT_CLICK_AREA_SIZE_KM" area_km = getattr(self.app_controller, area_km_cfg_name, 50.0) - context_menu.add_command( - label=f"Set {area_km:.0f}km Mon. Area", - command=lambda: self.app_controller.set_bbox_around_coords( - latitude, longitude, area_km - ), - ) + context_menu.add_command(label=f"Set {area_km:.0f}km Mon. Area", command=lambda: self.app_controller.set_bbox_around_coords(latitude, longitude, area_km)) context_menu.tk_popup(screen_x, screen_y) - except tk.TclError as e: - logger.warning(f"TclError showing MapCanvasManager menu: {e}.") - except Exception as e: - logger.error( - f"Error creating/showing MapCanvasManager menu: {e}", exc_info=True - ) + 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"MapCanvasManager: Request to recenter map @ Geo ({lat:.4f}, {lon:.4f})" - ) - if ( - self._current_zoom is not None - and self.canvas.winfo_exists() - and PIL_IMAGE_LIB_AVAILABLE - and MERCANTILE_MODULE_LOCALLY_AVAILABLE - and mercantile is not None - ): - self.recenter_and_redraw(lat, lon, self._current_zoom) - else: - logger.warning( - "Cannot recenter map: missing context (zoom, canvas, or libs)." - ) + 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"MapCanvasManager: Request to set BBox around Geo ({center_lat:.4f}, {center_lon:.4f}), size {area_size_km:.1f}km." - ) + 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("Cannot set BBox around coords: pyproj library not available.") - if self.app_controller and hasattr( - self.app_controller, "show_error_message" - ): - self.app_controller.show_error_message( - "Map Error", "Cannot calculate BBox: Geographic libraries missing." - ) + 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 - ) + bbox_tuple_wesn = get_bounding_box_from_center_size(center_lat, center_lon, area_size_km) if bbox_tuple_wesn: - bbox_dict = { - "lon_min": bbox_tuple_wesn[0], - "lat_min": bbox_tuple_wesn[1], - "lon_max": bbox_tuple_wesn[2], - "lat_max": bbox_tuple_wesn[3], - } + 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) else: - logger.error( - f"Calculated BBox around coords is invalid: {bbox_dict}." - ) - if self.app_controller and hasattr( - self.app_controller, "show_error_message" - ): - self.app_controller.show_error_message( - "Map Error", "Calculated BBox is invalid." - ) + 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"Failed to calculate BBox around coords ({center_lat}, {center_lon}, {area_size_km}km)." - ) - if self.app_controller and hasattr( - self.app_controller, "show_error_message" - ): - self.app_controller.show_error_message( - "Map Error", "Failed to calculate BBox around coordinates." - ) - except Exception as e: - logger.exception(f"Unexpected error calculating BBox around coords: {e}") - if self.app_controller and hasattr( - self.app_controller, "show_error_message" - ): - self.app_controller.show_error_message( - "Map Error", f"An unexpected error occurred calculating BBox: {e}" - ) + 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): - if ( - self._current_zoom is None - or self._current_center_lat is None - or self._current_center_lon is None - ): - logger.warning( - "Cannot zoom in: current map state (zoom/center) is not defined." - ) - return - if ( - not self.canvas.winfo_exists() - or not PIL_IMAGE_LIB_AVAILABLE - or not MERCANTILE_MODULE_LOCALLY_AVAILABLE - ): - logger.warning( - "Cannot zoom in: canvas or required libraries not available." - ) - return - max_zoom = ( - self.map_service.max_zoom - if self.map_service - else map_constants.DEFAULT_MAX_ZOOM_FALLBACK - ) - new_zoom = min(self._current_zoom + 1, max_zoom) - if new_zoom != self._current_zoom: - logger.info( - f"Zooming in from {self._current_zoom} to {new_zoom} at current center." - ) - self.recenter_and_redraw( - self._current_center_lat, self._current_center_lon, new_zoom - ) - else: - logger.debug( - f"Already at max zoom ({self._current_zoom}). Cannot zoom in further." - ) + 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): - if ( - self._current_zoom is None - or self._current_center_lat is None - or self._current_center_lon is None - ): - logger.warning( - "Cannot zoom out: current map state (zoom/center) is not defined." - ) - return - if ( - not self.canvas.winfo_exists() - or not PIL_IMAGE_LIB_AVAILABLE - or not MERCANTILE_MODULE_LOCALLY_AVAILABLE - ): - logger.warning( - "Cannot zoom out: canvas or required libraries not available." - ) - return - new_zoom = max(map_constants.MIN_ZOOM_LEVEL, self._current_zoom - 1) - if new_zoom != self._current_zoom: - logger.info( - f"Zooming out from {self._current_zoom} to {new_zoom} at current center." - ) - self.recenter_and_redraw( - self._current_center_lat, self._current_center_lon, new_zoom - ) - else: - logger.debug( - f"Already at min zoom ({self._current_zoom}). Cannot zoom out further." - ) - - def pan_map_fixed_step( - self, direction: str, step_fraction: float = PAN_STEP_FRACTION - ): - if ( - self._current_center_lat is None - or self._current_center_lon is None - or self._current_zoom is None - or self._current_map_geo_bounds is None - or self._map_pil_image is None - ): - logger.warning( - "Cannot pan map: current map state or image not fully defined." - ) - return - if ( - not self.canvas.winfo_exists() - or not PIL_IMAGE_LIB_AVAILABLE - or not MERCANTILE_MODULE_LOCALLY_AVAILABLE - or mercantile is None - or not PYPROJ_MODULE_LOCALLY_AVAILABLE - or pyproj is None - ): - logger.warning( - "Cannot pan map: canvas or required libraries not available." - ) - return + 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) - - if direction == "left": - delta_x_pixels = pan_step_px_w - elif direction == "right": - delta_x_pixels = -pan_step_px_w - elif direction == "up": - delta_y_pixels = pan_step_px_h - elif direction == "down": - delta_y_pixels = -pan_step_px_h - else: - logger.warning(f"Unknown pan direction: {direction}") - return - - map_west, map_south, map_east, map_north = self._current_map_geo_bounds - current_view_center_lon = (map_west + map_east) / 2.0 - current_view_center_lat = (map_south + map_north) / 2.0 - if map_west > map_east: - current_view_center_lon = (map_west + map_east + 360) / 2.0 - if current_view_center_lon > 180: - current_view_center_lon -= 360 - - res_m_px = calculate_meters_per_pixel( - current_view_center_lat, self._current_zoom, self.tile_manager.tile_size - ) + 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 + elif direction == "right": delta_x_pixels = -pan_step_px_w + elif direction == "up": delta_y_pixels = pan_step_px_h + elif direction == "down": delta_y_pixels = -pan_step_px_h + else: logger.warning(f"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}") + 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( - "Could not calculate valid resolution for panning. Cannot pan." - ) - return - + 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_meters_y = delta_y_pixels * res_m_px + logger.debug(f"MCM pan_map_fixed_step: Meter deltas dMx={delta_meters_x:.2f}, dMy={delta_meters_y:.2f}") geod = pyproj.Geod(ellps="WGS84") - new_center_lon, new_center_lat = ( - self._current_center_lon, - self._current_center_lat, - ) - + current_calc_lon, current_calc_lat = self._current_center_lon_gui, self._current_center_lat_gui if abs(delta_meters_x) > 1e-9: - azimuth_lon = 90.0 if delta_meters_x > 0 else 270.0 - clamped_start_lat = max(-89.99, min(89.99, new_center_lat)) - temp_lon, _, _ = geod.fwd( - new_center_lon, clamped_start_lat, azimuth_lon, abs(delta_meters_x) - ) - new_center_lon = temp_lon + azimuth_lon = 90.0 if delta_x_pixels > 0 else 270.0 + clamped_start_lat_fwd_lon = max(-89.999, min(89.999, current_calc_lat)) + temp_lon, _, _ = geod.fwd(current_calc_lon, clamped_start_lat_fwd_lon, azimuth_lon, abs(delta_meters_x)) + current_calc_lon = temp_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}") if abs(delta_meters_y) > 1e-9: - azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0 - clamped_start_lon = max(-179.99, min(179.99, new_center_lon)) - _, temp_lat, _ = geod.fwd( - clamped_start_lon, new_center_lat, azimuth_lat, abs(delta_meters_y) - ) - new_center_lat = temp_lat - + azimuth_lat = 180.0 if delta_y_pixels > 0 else 0.0 + clamped_start_lon_fwd_lat = max(-179.999, min(179.999, current_calc_lon)) + _, temp_lat, _ = geod.fwd(clamped_start_lon_fwd_lat, current_calc_lat, azimuth_lat, abs(delta_meters_y)) + current_calc_lat = temp_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 - new_center_lat = max(-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, new_center_lat)) - new_center_lon = (new_center_lon + 180) % 360 - 180 - logger.info( - f"Panning map '{direction}'. New target center: ({new_center_lat:.4f}, {new_center_lon:.4f})" - ) - self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom) + final_new_center_lat = max(-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, current_calc_lat)) + final_new_center_lon = (current_calc_lon + 180) % 360 - 180 + 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"Request to center map at ({center_lat:.4f}, {center_lon:.4f}) and fit patch of {patch_size_km}km." - ) - if ( - not self.canvas.winfo_exists() - or self.canvas_width <= 0 - or self.canvas_height <= 0 - ): - logger.error("Cannot fit patch: canvas not ready or invalid dimensions.") - if self.app_controller and hasattr( - self.app_controller, "show_error_message" - ): - self.app_controller.show_error_message( - "Map Error", "Canvas not ready to fit patch." - ) + 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 - if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: - logger.error( - "Cannot fit patch: PyProj library not available for geographic calculations." - ) - if self.app_controller and hasattr( - self.app_controller, "show_error_message" - ): - self.app_controller.show_error_message( - "Map Error", - "Geographic library (PyProj) missing for patch fitting.", - ) - return - zoom_for_width = calculate_zoom_level_for_geographic_size( - center_lat, - patch_size_km * 1000, - self.canvas_width, - self.tile_manager.tile_size, + 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, + 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"Could not calculate zoom to fit patch of {patch_size_km}km. Using current or default zoom." - ) - new_zoom = ( - self._current_zoom - if self._current_zoom is not None - else map_constants.DEFAULT_INITIAL_ZOOM - ) - else: - new_zoom = min(zoom_for_width, zoom_for_height) - - max_zoom_limit = ( - self.map_service.max_zoom - if self.map_service - else map_constants.DEFAULT_MAX_ZOOM_FALLBACK - ) - new_zoom = max(map_constants.MIN_ZOOM_LEVEL, min(new_zoom, max_zoom_limit)) - logger.info( - f"Centering and fitting patch. Target center: ({center_lat:.4f}, {center_lon:.4f}), Calculated Zoom: {new_zoom}" - ) - self.recenter_and_redraw(center_lat, center_lon, new_zoom) + 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) + 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) \ No newline at end of file diff --git a/flightmonitor/map/map_constants.py b/flightmonitor/map/map_constants.py index 5dc4493..785ad93 100644 --- a/flightmonitor/map/map_constants.py +++ b/flightmonitor/map/map_constants.py @@ -51,18 +51,20 @@ DEFAULT_MAX_ZOOM_FALLBACK: int = 19 # Default color for placeholder tile images when a tile cannot be loaded. DEFAULT_PLACEHOLDER_COLOR_RGB: Tuple[int, int, int] = (220, 220, 220) # Light grey +# MODIFIED: Added constant for Tkinter canvas background color for placeholders. +# WHY: Needed by MapCanvasManager to set the canvas background when displaying placeholder text. +# HOW: Added the new constant definition. +DEFAULT_PLACEHOLDER_COLOR_RGB_TK: str = "gray85" # Tkinter color string for canvas background + # --- Map Information Panel Formatting --- # Number of decimal places to display for coordinates in the info panel. COORDINATE_DECIMAL_PLACES: int = 5 -# MODIFIED: Add constant for decimal places for map size in km. -# WHY: Centralize formatting constant for map info panel. -# HOW: Added the constant. MAP_SIZE_KM_DECIMAL_PLACES: int = 1 # Number of decimal places for map size in km. # Define standard degree, minute, second symbols for DMS formatting DMS_DEGREE_SYMBOL: str = "°" DMS_MINUTE_SYMBOL: str = "'" -DMS_SECOND_SYMBOL: str = "''" +DMS_SECOND_SYMBOL: str = "''" \ No newline at end of file diff --git a/flightmonitor/utils/logger.py b/flightmonitor/utils/logger.py index 4d90eb7..51dd4d9 100644 --- a/flightmonitor/utils/logger.py +++ b/flightmonitor/utils/logger.py @@ -1,295 +1,235 @@ # FlightMonitor/utils/logger.py import logging +import logging.handlers # For RotatingFileHandler import tkinter as tk from tkinter.scrolledtext import ScrolledText -from queue import Queue, Empty as QueueEmpty +from queue import Queue, Empty as QueueEmpty # Renamed for clarity from typing import Optional, Dict, Any +# --- Module-level globals for the new centralized logging queue system --- +_global_log_queue: Optional[Queue[logging.LogRecord]] = None +_actual_console_handler: Optional[logging.StreamHandler] = None +_actual_file_handler: Optional[logging.handlers.RotatingFileHandler] = None +_actual_tkinter_handler: Optional["TkinterTextHandler"] = None # Forward declaration + +_log_processor_after_id: Optional[str] = None +_logging_system_active: bool = False +_tk_root_instance_for_processing: Optional[tk.Tk] = None +_base_formatter: Optional[logging.Formatter] = None # Store the main formatter + +# Interval for polling the global log queue by the GUI thread processor +GLOBAL_LOG_QUEUE_POLL_INTERVAL_MS = 50 + class TkinterTextHandler(logging.Handler): """ - A logging handler that directs log messages to a Tkinter Text widget - in a thread-safe manner using an internal queue and root.after(). + A logging handler that directs log messages to a Tkinter Text widget. + This handler itself is now called by _process_global_log_queue, + which runs in the GUI thread. Its internal queue is for batching updates + to the Text widget if needed, or could be simplified later. """ - def __init__( self, - text_widget: tk.Text, - root_tk_instance: tk.Tk, + text_widget: tk.Text, # ScrolledText is a tk.Text level_colors: Dict[int, str], - queue_poll_interval_ms: int, + # root_tk_instance and queue_poll_interval_ms for its own internal queue might be redundant + # if _process_global_log_queue handles batching, but kept for now. + # For now, this handler will still use its own internal queue and after loop for safety. + root_tk_instance_for_widget_update: tk.Tk, + internal_poll_interval_ms: int = 100 ): super().__init__() self.text_widget = text_widget - self.root_tk_instance = root_tk_instance - self.log_queue = Queue() self.level_colors = level_colors - self.queue_poll_interval_ms = queue_poll_interval_ms - self._after_id_log_processor: Optional[str] = None - self._is_active = True + self._is_active = True # Internal active state for this specific handler + self._widget_update_queue = Queue() + self._root_for_widget_update = root_tk_instance_for_widget_update + self._internal_poll_interval_ms = internal_poll_interval_ms + self._internal_after_id: Optional[str] = None if not ( self.text_widget and hasattr(self.text_widget, "winfo_exists") and self.text_widget.winfo_exists() ): - print( - "Warning: TkinterTextHandler initialized with an invalid or non-existent text_widget.", - flush=True, - ) + print("ERROR: TkinterTextHandler initialized with an invalid or non-existent text_widget.", flush=True) self._is_active = False return if not ( - self.root_tk_instance - and hasattr(self.root_tk_instance, "winfo_exists") - and self.root_tk_instance.winfo_exists() + self._root_for_widget_update + and hasattr(self._root_for_widget_update, "winfo_exists") + and self._root_for_widget_update.winfo_exists() ): - print( - "Warning: TkinterTextHandler initialized with an invalid or non-existent root_tk_instance.", - flush=True, - ) + print("ERROR: TkinterTextHandler initialized with an invalid root for widget update.", flush=True) self._is_active = False return if self._is_active: - for level, color_value in self.level_colors.items(): - level_name = logging.getLevelName(level) - if color_value: - try: - self.text_widget.tag_config(level_name, foreground=color_value) - except tk.TclError: - print( - f"Warning: Could not configure tag for {level_name} during TkinterTextHandler init.", - flush=True, - ) - pass + self._configure_tags() + self._process_widget_update_queue() # Start its own update loop - if self._is_active: - self._process_log_queue() + def _configure_tags(self): + if not self._is_active or not self.text_widget.winfo_exists(): return + for level, color_value in self.level_colors.items(): + level_name = logging.getLevelName(level) + if color_value: + try: + self.text_widget.tag_config(level_name, foreground=color_value) + except tk.TclError: + print(f"Warning: Could not configure tag for {level_name} in TkinterTextHandler.", flush=True) def emit(self, record: logging.LogRecord): - if not self._is_active: + # This emit is called by _process_global_log_queue (already in GUI thread) + if not self._is_active or not self.text_widget.winfo_exists(): + # This case should ideally not happen if setup is correct + print(f"DEBUG: TkinterTextHandler.emit called but inactive or widget gone. Record: {record.getMessage()}", flush=True) return - - if not ( - hasattr(self.text_widget, "winfo_exists") - and self.text_widget.winfo_exists() - and hasattr(self.root_tk_instance, "winfo_exists") - and self.root_tk_instance.winfo_exists() - ): - print( - "Warning: TkinterTextHandler.emit: Widget or root instance does not exist. Deactivating handler.", - flush=True, - ) - self._is_active = False - return - try: - msg = self.format(record) + msg = self.format(record) # Format the record using the formatter set on this handler level_name = record.levelname - self.log_queue.put_nowait((level_name, msg)) + # Put in its own queue for batched widget updates + self._widget_update_queue.put_nowait((level_name, msg)) except Exception as e: - print(f"Error in TkinterTextHandler.emit before queueing: {e}", flush=True) + print(f"Error in TkinterTextHandler.emit (to internal queue): {e}", flush=True) - def _process_log_queue(self): - if not self._is_active: - if ( - self._after_id_log_processor - and hasattr(self.root_tk_instance, "winfo_exists") - and self.root_tk_instance.winfo_exists() - ): - try: - self.root_tk_instance.after_cancel(self._after_id_log_processor) - except tk.TclError: - pass - except Exception as e: - print( - f"Error cancelling after_id in _process_log_queue (handler inactive): {e}", - flush=True, - ) - self._after_id_log_processor = None - return - - if not ( - hasattr(self.text_widget, "winfo_exists") - and self.text_widget.winfo_exists() - and hasattr(self.root_tk_instance, "winfo_exists") - and self.root_tk_instance.winfo_exists() - ): - print( - "Debug: TkinterTextHandler._process_log_queue: Widget or root destroyed. Stopping.", - flush=True, - ) - if self._after_id_log_processor: - try: - self.root_tk_instance.after_cancel(self._after_id_log_processor) - except tk.TclError: - pass - except Exception as e: - print( - f"Error cancelling after_id in _process_log_queue (widgets gone): {e}", - flush=True, - ) - self._after_id_log_processor = None + def _process_widget_update_queue(self): + if not self._is_active or \ + not self.text_widget.winfo_exists() or \ + not self._root_for_widget_update.winfo_exists(): self._is_active = False + if self._internal_after_id and self._root_for_widget_update.winfo_exists(): + try: self._root_for_widget_update.after_cancel(self._internal_after_id) + except Exception: pass + self._internal_after_id = None + # print("DEBUG: TkinterTextHandler._process_widget_update_queue stopping (inactive/widget gone).", flush=True) return + had_messages = False try: - while self._is_active: - try: - level_name, msg = self.log_queue.get(block=False, timeout=0.01) - except QueueEmpty: - break - except Exception as e_get: - print( - f"Error getting from log queue: {e_get}. Stopping processing for this cycle.", - flush=True, - ) - break - - if not self._is_active or not ( - self.text_widget.winfo_exists() - and self.root_tk_instance.winfo_exists() - ): - print( - "Debug: TkinterTextHandler._process_log_queue: Widgets gone during queue processing loop. Stopping.", - flush=True, - ) - self._is_active = False - break - - try: + while not self._widget_update_queue.empty(): + had_messages = True + level_name, msg = self._widget_update_queue.get_nowait() + if self.text_widget.winfo_exists(): self.text_widget.configure(state=tk.NORMAL) - if self.text_widget.winfo_exists(): - self.text_widget.insert(tk.END, msg + "\n", (level_name,)) - self.text_widget.see(tk.END) - if self.text_widget.winfo_exists(): - self.text_widget.configure(state=tk.DISABLED) - self.log_queue.task_done() + self.text_widget.insert(tk.END, msg + "\n", (level_name,)) + self.text_widget.configure(state=tk.DISABLED) + else: # Widget destroyed during processing + self._is_active = False; break + self._widget_update_queue.task_done() + if had_messages and self.text_widget.winfo_exists(): + self.text_widget.see(tk.END) - except tk.TclError as e_tcl_inner: - print( - f"TkinterTextHandler TclError during widget update: {e_tcl_inner}. Attempting to stop handler.", - flush=True, - ) - self._is_active = False - break - except Exception as e_inner: - print( - f"Unexpected error updating text widget: {e_inner}. Attempting to stop handler.", - flush=True, - ) - self._is_active = False - break - - except tk.TclError as e_tcl_outer: - print( - f"TkinterTextHandler TclError in _process_log_queue (outer): {e_tcl_outer}", - flush=True, - ) - if self._after_id_log_processor: - try: - self.root_tk_instance.after_cancel(self._after_id_log_processor) - except tk.TclError: - pass - except Exception as e_cancel: - print( - f"Error cancelling after_id in _process_log_queue (TclError outer): {e_cancel}", - flush=True, - ) - self._after_id_log_processor = None + except tk.TclError as e_tcl: + print(f"TkinterTextHandler TclError during widget update: {e_tcl}. Handler stopping.", flush=True) self._is_active = False - return - - except Exception as e_outer: - print( - f"Unexpected error in TkinterTextHandler._process_log_queue (outer): {e_outer}", - flush=True, - ) + except Exception as e_update: + print(f"Unexpected error updating TkinterTextHandler widget: {e_update}. Handler stopping.", flush=True) self._is_active = False - return - if ( - self._is_active - and hasattr(self.root_tk_instance, "winfo_exists") - and self.root_tk_instance.winfo_exists() - ): - try: - self._after_id_log_processor = self.root_tk_instance.after( - self.queue_poll_interval_ms, self._process_log_queue - ) - except tk.TclError: - print( - "Debug: TkinterTextHandler._process_log_queue: Root destroyed. Cannot reschedule.", - flush=True, - ) - self._after_id_log_processor = None - self._is_active = False - except Exception as e_reschedule: - print( - f"Error rescheduling _process_log_queue: {e_reschedule}", flush=True - ) - self._after_id_log_processor = None - self._is_active = False - elif self._after_id_log_processor: - if ( - hasattr(self.root_tk_instance, "winfo_exists") - and self.root_tk_instance.winfo_exists() - ): - try: - self.root_tk_instance.after_cancel(self._after_id_log_processor) - except tk.TclError: - pass - except Exception as e_cancel_final: - print( - f"Error cancelling after_id in final _process_log_queue check: {e_cancel_final}", - flush=True, - ) - self._after_id_log_processor = None + if self._is_active and self._root_for_widget_update.winfo_exists(): + self._internal_after_id = self._root_for_widget_update.after( + self._internal_poll_interval_ms, self._process_widget_update_queue + ) + else: # Ensure it's cleaned up if we are stopping + if self._internal_after_id and self._root_for_widget_update.winfo_exists(): + try: self._root_for_widget_update.after_cancel(self._internal_after_id) + except Exception: pass + self._internal_after_id = None def close(self): - """ - Cleans up resources, like stopping the log queue processor. - Called when the handler is removed or logging system shuts down. - """ + print("INFO: TkinterTextHandler close called.", flush=True) self._is_active = False - - if self._after_id_log_processor: - if ( - hasattr(self.root_tk_instance, "winfo_exists") - and self.root_tk_instance.winfo_exists() - ): - try: - self.root_tk_instance.after_cancel(self._after_id_log_processor) - except tk.TclError: - print( - f"Debug: TclError during after_cancel in TkinterTextHandler.close (root might be gone or invalid).", - flush=True, - ) - pass - except Exception as e_cancel_close: - print( - f"Error cancelling after_id in close: {e_cancel_close}", - flush=True, - ) - pass - self._after_id_log_processor = None - - while not self.log_queue.empty(): + if self._internal_after_id and hasattr(self, '_root_for_widget_update') and \ + self._root_for_widget_update and self._root_for_widget_update.winfo_exists(): try: - self.log_queue.get_nowait() - pass - except QueueEmpty: - break - except Exception as e_q_drain: - print(f"Error draining log queue during close: {e_q_drain}", flush=True) - break - + self._root_for_widget_update.after_cancel(self._internal_after_id) + except Exception: pass + self._internal_after_id = None + # Drain its internal queue + while not self._widget_update_queue.empty(): + try: self._widget_update_queue.get_nowait(); self._widget_update_queue.task_done() + except Exception: break super().close() -_tkinter_handler_instance: Optional[TkinterTextHandler] = None +class QueuePuttingHandler(logging.Handler): + """ + A simple handler that puts any received LogRecord into a global queue. + """ + def __init__(self, handler_queue: Queue[logging.LogRecord]): + super().__init__() + self.handler_queue = handler_queue + + def emit(self, record: logging.LogRecord): + try: + # We can choose to make a copy of the record if there's any concern + # about it being modified before processing, though typically not an issue. + # For now, put the original record. + self.handler_queue.put_nowait(record) + except queue.Full: + print(f"CRITICAL: Global log queue is full! Log record for '{record.name}' might be lost.", flush=True) + except Exception as e: + print(f"CRITICAL: Error putting log record into global queue: {e}", flush=True) + + +def _process_global_log_queue(): + """ + GUI Thread: Periodically processes LogRecords from the _global_log_queue + and dispatches them to the actual configured handlers. + """ + global _logging_system_active, _log_processor_after_id + # print(f"DEBUG: _process_global_log_queue called. Active: {_logging_system_active}", flush=True) # Can be very verbose + + if not _logging_system_active: + if _log_processor_after_id and _tk_root_instance_for_processing and _tk_root_instance_for_processing.winfo_exists(): + try: _tk_root_instance_for_processing.after_cancel(_log_processor_after_id) + except Exception: pass + _log_processor_after_id = None + return + + if not (_tk_root_instance_for_processing and _tk_root_instance_for_processing.winfo_exists()): + # print("DEBUG: Global log queue processor: Tk root instance gone. Stopping.", flush=True) + _logging_system_active = False # Stop if root is gone + _log_processor_after_id = None + return + + processed_count = 0 + try: + while _global_log_queue and not _global_log_queue.empty(): + if not _logging_system_active: break # Check before processing each item + try: + record = _global_log_queue.get_nowait() + processed_count +=1 + + # Dispatch to actual handlers + if _actual_console_handler: + try: _actual_console_handler.handle(record) + except Exception as e_con: print(f"Error in console_handler.handle: {e_con}", flush=True) + + if _actual_file_handler: + try: _actual_file_handler.handle(record) + except Exception as e_file: print(f"Error in file_handler.handle: {e_file}", flush=True) + + if _actual_tkinter_handler: + try: _actual_tkinter_handler.handle(record) + except Exception as e_tk: print(f"Error in tkinter_handler.handle: {e_tk}", flush=True) + + _global_log_queue.task_done() + except QueueEmpty: + break # Should not happen due to outer loop condition, but defensive + except Exception as e_proc_item: + print(f"Error processing a log item from global queue: {e_proc_item}", flush=True) + # Potentially re-queue or log to a failsafe if critical + except Exception as e_outer_loop: + print(f"Critical error in _process_global_log_queue outer loop: {e_outer_loop}", flush=True) + + + if _logging_system_active and _tk_root_instance_for_processing.winfo_exists(): + _log_processor_after_id = _tk_root_instance_for_processing.after( + GLOBAL_LOG_QUEUE_POLL_INTERVAL_MS, _process_global_log_queue + ) + # else: print(f"DEBUG: Not rescheduling _process_global_log_queue. Active: {_logging_system_active}", flush=True) def setup_logging( @@ -297,195 +237,157 @@ def setup_logging( root_tk_instance: Optional[tk.Tk] = None, logging_config_dict: Optional[Dict[str, Any]] = None, ): - """ - Sets up application-wide logging based on the provided configuration dictionary. - If logging_config_dict is None, uses basic default settings (console only). - """ - global _tkinter_handler_instance + global _global_log_queue, _actual_console_handler, _actual_file_handler + global _actual_tkinter_handler, _logging_system_active, _tk_root_instance_for_processing + global _log_processor_after_id, _base_formatter + + print("INFO: Configuring centralized queued logging system...", flush=True) + + if _logging_system_active: + print("INFO: Logging system already active. Shutting down existing to reconfigure.", flush=True) + shutdown_logging_system() # Ensure clean state before re-setup if logging_config_dict is None: - print( - "Warning: No logging_config_dict provided to setup_logging. Using basic default console logging.", - flush=True, - ) + print("Warning: No logging_config_dict. Using basic console-only defaults for queued logging.", flush=True) logging_config_dict = { - "default_root_level": logging.INFO, - "specific_levels": {}, - "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + "default_root_level": logging.INFO, "specific_levels": {}, + "format": "%(asctime)s [%(levelname)-8s] %(name)-25s : %(message)s", "date_format": "%Y-%m-%d %H:%M:%S", - "colors": {logging.INFO: "black"}, - "queue_poll_interval_ms": 100, - "enable_console": True, - "enable_file": False, + "colors": {logging.INFO: "black", logging.DEBUG: "blue", logging.WARNING:"orange", logging.ERROR:"red", logging.CRITICAL:"purple"}, + "queue_poll_interval_ms": 100, # For TkinterTextHandler's internal queue + "enable_console": True, "enable_file": False, } + # --- Extract Config --- root_log_level = logging_config_dict.get("default_root_level", logging.INFO) specific_levels = logging_config_dict.get("specific_levels", {}) - log_format_str = logging_config_dict.get( - "format", "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) + log_format_str = logging_config_dict.get("format", "%(asctime)s [%(levelname)-8s] %(name)-25s : %(message)s") log_date_format_str = logging_config_dict.get("date_format", "%Y-%m-%d %H:%M:%S") level_colors = logging_config_dict.get("colors", {}) - queue_poll_interval_ms = logging_config_dict.get("queue_poll_interval_ms", 100) + tkinter_handler_poll_ms = logging_config_dict.get("queue_poll_interval_ms", 100) # For TkinterTextHandler's internal queue enable_console = logging_config_dict.get("enable_console", True) - - # MODIFIED: De-commented the line to extract 'enable_file' from the config dictionary. - # WHY: The variable 'enable_file' was used later in an 'if' statement but was not defined because this line was commented out. - # HOW: Removed the '#' at the beginning of the line. enable_file = logging_config_dict.get("enable_file", False) - formatter = logging.Formatter(log_format_str, datefmt=log_date_format_str) + _base_formatter = logging.Formatter(log_format_str, datefmt=log_date_format_str) + _global_log_queue = Queue() # Initialize the global queue for LogRecords + _tk_root_instance_for_processing = root_tk_instance # Store for the after() loop + # --- Configure Root Logger --- root_logger = logging.getLogger() - - handlers_to_remove = [] + # Remove ALL existing handlers from the root logger to ensure clean state for handler in root_logger.handlers[:]: - if isinstance(handler, (logging.StreamHandler, logging.FileHandler)): - handlers_to_remove.append(handler) - elif handler is _tkinter_handler_instance: - pass - else: - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.warning( - f"Removing unknown logger handler during setup: {handler}" - ) - handlers_to_remove.append(handler) - - for handler in handlers_to_remove: try: - handler.close() + handler.close() # Close handler first root_logger.removeHandler(handler) - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.debug(f"Removed and closed old handler: {handler}") - except Exception as e: - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.error( - f"Error removing/closing old handler {handler}: {e}", exc_info=False - ) - - if _tkinter_handler_instance: - if _tkinter_handler_instance in root_logger.handlers: - try: - root_logger.removeHandler(_tkinter_handler_instance) - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.debug("Removed old TkinterTextHandler instance.") - except Exception as e: - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.error( - f"Error removing old TkinterTextHandler instance: {e}", - exc_info=False, - ) - - try: - _tkinter_handler_instance.close() - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.debug("Closed old TkinterTextHandler instance.") - except Exception as e: - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.error( - f"Error closing old TkinterTextHandler instance: {e}", exc_info=False - ) - finally: - _tkinter_handler_instance = None + print(f"DEBUG: Removed and closed old handler from root: {handler}", flush=True) + except Exception as e_rem: + print(f"Error removing/closing old handler {handler}: {e_rem}", flush=True) + root_logger.handlers = [] # Ensure it's empty root_logger.setLevel(root_log_level) - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.debug( - f"Root logger level set to {logging.getLevelName(root_logger.level)}" - ) + print(f"INFO: Root logger level set to {logging.getLevelName(root_logger.level)}.", flush=True) + # --- Configure Specific Logger Levels --- for logger_name, level in specific_levels.items(): named_logger = logging.getLogger(logger_name) named_logger.setLevel(level) - module_logger_internal.debug( - f"Logger '{logger_name}' level set to {logging.getLevelName(named_logger.level)}" - ) + # Ensure propagation is on if they are not root, or if they should also go to root's QueuePuttingHandler + named_logger.propagate = True + print(f"INFO: Logger '{logger_name}' level set to {logging.getLevelName(named_logger.level)}.", flush=True) + + # --- Create Actual Handlers (but don't add to root logger yet) --- if enable_console: try: - console_handler = logging.StreamHandler() - console_handler.setFormatter(formatter) - root_logger.addHandler(console_handler) - module_logger_internal.debug("Console logging enabled and handler added.") + _actual_console_handler = logging.StreamHandler() # Defaults to stderr + _actual_console_handler.setFormatter(_base_formatter) + _actual_console_handler.setLevel(logging.DEBUG) # Let console handler decide based on record level + print("INFO: Actual ConsoleHandler created.", flush=True) except Exception as e: - module_logger_internal.error( - f"Error adding console handler: {e}", exc_info=False - ) + print(f"ERROR: Failed to create actual ConsoleHandler: {e}", flush=True) + _actual_console_handler = None - # MODIFIED: Added the block to configure the file handler if enable_file is True. - # WHY: This logic was intended to be here. - # HOW: Moved the block from the original code and ensured it's under the 'if enable_file:' condition. if enable_file: try: - from logging.handlers import RotatingFileHandler - - file_path = logging_config_dict.get("file_path", "app.log") - file_max_bytes = logging_config_dict.get("file_max_bytes", 10 * 1024 * 1024) - file_backup_count = logging_config_dict.get("file_backup_count", 5) - file_handler = RotatingFileHandler( - file_path, maxBytes=file_max_bytes, backupCount=file_backup_count - ) - file_handler.setFormatter(formatter) - root_logger.addHandler(file_handler) - module_logger_internal.debug( - f"File logging enabled to '{file_path}' and handler added." + file_path = logging_config_dict.get("file_path", "flight_monitor_app.log") + file_max_bytes = logging_config_dict.get("file_max_bytes", 5 * 1024 * 1024) # 5MB + file_backup_count = logging_config_dict.get("file_backup_count", 3) + _actual_file_handler = logging.handlers.RotatingFileHandler( + file_path, maxBytes=file_max_bytes, backupCount=file_backup_count, encoding='utf-8' ) + _actual_file_handler.setFormatter(_base_formatter) + _actual_file_handler.setLevel(logging.DEBUG) # Let file handler decide + print(f"INFO: Actual RotatingFileHandler created for '{file_path}'.", flush=True) except Exception as e: - module_logger_internal.error( - f"Error adding file handler to '{file_path}': {e}", exc_info=True - ) + print(f"ERROR: Failed to create actual FileHandler for '{file_path}': {e}", flush=True) + _actual_file_handler = None if gui_log_widget and root_tk_instance: is_widget_valid = ( - isinstance(gui_log_widget, (tk.Text, ScrolledText)) - and hasattr(gui_log_widget, "winfo_exists") - and gui_log_widget.winfo_exists() + isinstance(gui_log_widget, (tk.Text, ScrolledText)) and + hasattr(gui_log_widget, "winfo_exists") and gui_log_widget.winfo_exists() ) - is_root_valid = ( - hasattr(root_tk_instance, "winfo_exists") - and root_tk_instance.winfo_exists() - ) - - if not is_widget_valid: - print( - f"ERROR: GUI log widget is not a valid tk.Text/ScrolledText or does not exist: {type(gui_log_widget)}", - flush=True, - ) - elif not is_root_valid: - print( - "WARNING: Root Tk instance provided to setup_logging does not exist.", - flush=True, - ) - else: + if is_widget_valid: try: - _tkinter_handler_instance = TkinterTextHandler( + _actual_tkinter_handler = TkinterTextHandler( text_widget=gui_log_widget, - root_tk_instance=root_tk_instance, level_colors=level_colors, - queue_poll_interval_ms=queue_poll_interval_ms, - ) - _tkinter_handler_instance.setFormatter(formatter) - root_logger.addHandler(_tkinter_handler_instance) - module_logger_internal = logging.getLogger(__name__) - module_logger_internal.info( - "GUI logging handler (thread-safe) initialized and attached." + root_tk_instance_for_widget_update=root_tk_instance, # Pass root for its own after loop + internal_poll_interval_ms=tkinter_handler_poll_ms ) + _actual_tkinter_handler.setFormatter(_base_formatter) + _actual_tkinter_handler.setLevel(logging.DEBUG) # Let Tkinter handler decide + print("INFO: Actual TkinterTextHandler created.", flush=True) except Exception as e: - _tkinter_handler_instance = None - print( - f"ERROR: Failed to initialize TkinterTextHandler: {e}", flush=True - ) - + print(f"ERROR: Failed to create actual TkinterTextHandler: {e}", flush=True) + _actual_tkinter_handler = None + else: + print("ERROR: GUI log widget invalid or non-existent, cannot create TkinterTextHandler.", flush=True) elif gui_log_widget and not root_tk_instance: - print( - "WARNING: GUI log widget provided, but root Tk instance is missing. Cannot initialize GUI logger.", - flush=True, - ) - elif not gui_log_widget and root_tk_instance: - print( - "DEBUG: Root Tk instance provided, but no GUI log widget. GUI logger not initialized.", - flush=True, + print("WARNING: GUI log widget provided, but root_tk_instance missing for TkinterTextHandler.", flush=True) + + + # --- Add ONLY the QueuePuttingHandler to the root logger --- + if _global_log_queue is not None: + queue_putter = QueuePuttingHandler(handler_queue=_global_log_queue) + queue_putter.setLevel(logging.DEBUG) # Capture all messages from root level downwards + root_logger.addHandler(queue_putter) + print(f"INFO: QueuePuttingHandler added to root logger. All logs will go to global queue.", flush=True) + else: + print("CRITICAL ERROR: Global log queue not initialized. Logging will not function correctly.", flush=True) + # Fallback to basic console if queue setup failed badly + if not _actual_console_handler: # If even this failed + _bf = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + _ch = logging.StreamHandler() + _ch.setFormatter(_bf) + root_logger.addHandler(_ch) + print("CRITICAL: Added basic emergency console logger.", flush=True) + + + # --- Start the global log queue processor (if root_tk_instance is available) --- + _logging_system_active = True + if _tk_root_instance_for_processing and _tk_root_instance_for_processing.winfo_exists(): + if _log_processor_after_id: # Cancel previous if any (shouldn't happen if shutdown was called) + try: _tk_root_instance_for_processing.after_cancel(_log_processor_after_id) + except Exception: pass + _log_processor_after_id = _tk_root_instance_for_processing.after( + GLOBAL_LOG_QUEUE_POLL_INTERVAL_MS, _process_global_log_queue ) + print(f"INFO: Global log queue processor scheduled with Tkinter.after (ID: {_log_processor_after_id}).", flush=True) + elif root_tk_instance is None and (gui_log_widget is not None or enable_file or enable_console): + # This case means we want logging but have no Tkinter root to schedule the queue processor. + # This is problematic for GUI logging. For console/file, logs will just queue up. + # A dedicated thread could process the queue if no Tkinter available, but adds complexity. + print("WARNING: No Tkinter root instance provided to setup_logging. " + "Logs will be queued but not processed by GUI/File/Console handlers unless _process_global_log_queue is run manually or by another mechanism.", flush=True) + elif not (enable_console or enable_file or gui_log_widget): + print("INFO: No log output handlers (console, file, gui) are enabled. Logs will be queued but not displayed.", flush=True) + + + print("INFO: Centralized queued logging system setup complete.", flush=True) + # Test log + test_logger = logging.getLogger("FlightMonitor.LoggerTest") + test_logger.info("Logging system initialized. This is a test message from setup_logging.") def get_logger(name: str) -> logging.Logger: @@ -496,30 +398,84 @@ def get_logger(name: str) -> logging.Logger: return logging.getLogger(name) -def shutdown_gui_logging(): +def shutdown_logging_system(): """ - Closes and removes the TkinterTextHandler instance from the root logger. - This should be called before the Tkinter root window is destroyed. + Shuts down the centralized logging system, processing remaining logs + and closing actual handlers. """ - global _tkinter_handler_instance - root_logger = logging.getLogger() - if _tkinter_handler_instance: - if _tkinter_handler_instance in root_logger.handlers: - print( - f"INFO: Closing and removing GUI logging handler ({_tkinter_handler_instance.name}).", - flush=True, - ) - try: - root_logger.removeHandler(_tkinter_handler_instance) - except Exception as e: - print(f"Error removing GUI handler from logger: {e}", flush=True) + global _logging_system_active, _log_processor_after_id + global _actual_console_handler, _actual_file_handler, _actual_tkinter_handler + global _global_log_queue, _tk_root_instance_for_processing + print("INFO: Initiating shutdown of centralized logging system...", flush=True) + _logging_system_active = False # Signal processor to stop + + if _log_processor_after_id and _tk_root_instance_for_processing and _tk_root_instance_for_processing.winfo_exists(): try: - _tkinter_handler_instance.close() - print("INFO: GUI logging handler has been shut down.", flush=True) - except Exception as e: - print(f"Error closing GUI logging handler: {e}", flush=True) - finally: - _tkinter_handler_instance = None - else: - print("DEBUG: No active GUI logging handler to shut down.", flush=True) + _tk_root_instance_for_processing.after_cancel(_log_processor_after_id) + print("INFO: Cancelled global log queue processor task.", flush=True) + except Exception as e_cancel: + print(f"Warning: Error cancelling log processor task: {e_cancel}", flush=True) + _log_processor_after_id = None + + # Attempt to process any remaining logs in the global queue + print("INFO: Processing any remaining logs in the global queue before full shutdown...", flush=True) + if _global_log_queue: + final_processed_count = 0 + while not _global_log_queue.empty(): + try: + record = _global_log_queue.get_nowait() + final_processed_count += 1 + if _actual_console_handler: _actual_console_handler.handle(record) + if _actual_file_handler: _actual_file_handler.handle(record) + if _actual_tkinter_handler: _actual_tkinter_handler.handle(record) + _global_log_queue.task_done() + except QueueEmpty: + break + except Exception as e_final_proc: + print(f"Error during final log processing: {e_final_proc}", flush=True) + break # Stop if error during final processing + if final_processed_count > 0: + print(f"INFO: Processed {final_processed_count} remaining log messages.", flush=True) + else: + print("INFO: No remaining log messages in global queue to process.", flush=True) + + + # Close actual handlers + if _actual_tkinter_handler: + try: + print("INFO: Closing actual TkinterTextHandler.", flush=True) + _actual_tkinter_handler.close() + except Exception as e: print(f"Error closing TkinterTextHandler: {e}", flush=True) + _actual_tkinter_handler = None + + if _actual_console_handler: + try: + print("INFO: Closing actual ConsoleHandler.", flush=True) + _actual_console_handler.close() + except Exception as e: print(f"Error closing ConsoleHandler: {e}", flush=True) + _actual_console_handler = None + + if _actual_file_handler: + try: + print("INFO: Closing actual FileHandler.", flush=True) + _actual_file_handler.close() + except Exception as e: print(f"Error closing FileHandler: {e}", flush=True) + _actual_file_handler = None + + # Clear the root logger's handlers (should only be the QueuePuttingHandler) + root_logger = logging.getLogger() + for handler in root_logger.handlers[:]: + try: + if isinstance(handler, QueuePuttingHandler): # Specifically target our handler + handler.close() + root_logger.removeHandler(handler) + print(f"INFO: Removed and closed QueuePuttingHandler: {handler}", flush=True) + except Exception as e_rem_qph: + print(f"Error removing QueuePuttingHandler: {e_rem_qph}", flush=True) + + + _global_log_queue = None # Allow it to be garbage collected + _tk_root_instance_for_processing = None # Clear reference + _base_formatter = None + print("INFO: Centralized logging system shutdown complete.", flush=True) \ No newline at end of file