1793 lines
77 KiB
Python
1793 lines
77 KiB
Python
# FlightMonitor/map/map_canvas_manager.py
|
|
|
|
import tkinter as tk
|
|
import math
|
|
import time
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
from collections import deque
|
|
import queue
|
|
import threading
|
|
from . import map_utils # map_utils importa pyproj e mercantile
|
|
import copy
|
|
|
|
try:
|
|
from PIL import Image, ImageTk, ImageDraw, ImageFont
|
|
|
|
PIL_IMAGE_LIB_AVAILABLE = True
|
|
except ImportError:
|
|
Image, ImageTk, ImageDraw, ImageFont = None, None, None, None # type: ignore
|
|
PIL_IMAGE_LIB_AVAILABLE = False
|
|
import logging
|
|
|
|
logging.error(
|
|
"MapCanvasManager: Pillow (Image, ImageTk, ImageDraw, ImageFont) not found. Map disabled."
|
|
)
|
|
|
|
from .map_utils import (
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE,
|
|
MERCANTILE_MODULE_LOCALLY_AVAILABLE,
|
|
)
|
|
|
|
from . import map_constants
|
|
from ..data import config as app_config
|
|
from ..data.common_models import CanonicalFlightState
|
|
from .map_services import BaseMapService, OpenStreetMapService
|
|
from .map_tile_manager import MapTileManager
|
|
from .map_utils import (
|
|
get_tile_ranges_for_bbox,
|
|
calculate_geographic_bbox_size_km,
|
|
calculate_geographic_bbox_from_pixel_size_and_zoom,
|
|
_is_valid_bbox_dict,
|
|
_pixel_to_geo,
|
|
calculate_meters_per_pixel,
|
|
calculate_zoom_level_for_geographic_size,
|
|
get_bounding_box_from_center_size,
|
|
)
|
|
from . import map_drawing
|
|
|
|
try:
|
|
from ..utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
except ImportError:
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning("MapCanvasManager using fallback standard Python logger.")
|
|
|
|
|
|
CANVAS_SIZE_HARD_FALLBACK_PX = getattr(app_config, "DEFAULT_CANVAS_WIDTH", 800)
|
|
MAP_TILE_CACHE_DIR_HARD_FALLBACK = getattr(
|
|
app_config, "MAP_TILE_CACHE_DIR", "flightmonitor_tile_cache_fallback"
|
|
)
|
|
RESIZE_DEBOUNCE_DELAY_MS = 250
|
|
PAN_STEP_FRACTION = 0.25
|
|
DEFAULT_MAX_TRACK_POINTS = getattr(app_config, "DEFAULT_TRACK_HISTORY_POINTS", 20)
|
|
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:
|
|
|
|
def __init__(
|
|
self,
|
|
app_controller: Any,
|
|
tk_canvas: tk.Canvas,
|
|
initial_bbox_dict: Optional[Dict[str, float]],
|
|
is_detail_map: bool = False,
|
|
):
|
|
self.is_detail_map = is_detail_map
|
|
self.log_prefix = f"MCM (detail={self.is_detail_map})" # Store log_prefix
|
|
logger.info(f">>> {self.log_prefix} __init__ STARTING <<<")
|
|
|
|
if (
|
|
not PIL_IMAGE_LIB_AVAILABLE
|
|
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
or map_utils.mercantile is None # Check the imported module in map_utils
|
|
):
|
|
critical_msg = f"{self.log_prefix}: Critical dependencies missing: Pillow or Mercantile. Map disabled."
|
|
logger.critical(critical_msg)
|
|
if (
|
|
app_controller
|
|
and hasattr(app_controller, "main_window")
|
|
and app_controller.main_window
|
|
):
|
|
if hasattr(app_controller.main_window, "show_error_message"):
|
|
try:
|
|
app_controller.main_window.show_error_message(
|
|
"Map Initialization Error",
|
|
critical_msg.replace(f"{self.log_prefix}: ", ""),
|
|
)
|
|
except Exception:
|
|
pass
|
|
raise ImportError(critical_msg)
|
|
|
|
self.app_controller = app_controller
|
|
self.canvas = tk_canvas
|
|
|
|
self.canvas_width = self.canvas.winfo_width()
|
|
if self.canvas_width <= 1:
|
|
self.canvas_width = CANVAS_SIZE_HARD_FALLBACK_PX
|
|
self.canvas_height = self.canvas.winfo_height()
|
|
if self.canvas_height <= 1:
|
|
self.canvas_height = getattr(app_config, "DEFAULT_CANVAS_HEIGHT", 600)
|
|
|
|
logger.info(
|
|
f"{self.log_prefix} __init__: Canvas dims {self.canvas_width}x{self.canvas_height}"
|
|
)
|
|
|
|
self._current_center_lat_gui: Optional[float] = None
|
|
self._current_center_lon_gui: Optional[float] = None
|
|
self._current_zoom_gui: int = map_constants.DEFAULT_INITIAL_ZOOM
|
|
self._current_map_geo_bounds_gui: Optional[
|
|
Tuple[float, float, float, float]
|
|
] = None
|
|
self._target_bbox_input_gui: Optional[Dict[str, float]] = None
|
|
self._map_photo_image: Optional[ImageTk.PhotoImage] = None
|
|
self._canvas_image_id: Optional[int] = None
|
|
self._placeholder_text_id: Optional[int] = None
|
|
self.map_service: BaseMapService = OpenStreetMapService()
|
|
self.tile_manager: MapTileManager = MapTileManager(
|
|
map_service=self.map_service,
|
|
cache_root_directory=MAP_TILE_CACHE_DIR_HARD_FALLBACK,
|
|
tile_pixel_size=self.map_service.tile_size,
|
|
)
|
|
logger.info(f"{self.log_prefix} __init__: MapTileManager initialized.")
|
|
self._current_flights_to_display_gui: List[CanonicalFlightState] = []
|
|
self.flight_tracks_gui: Dict[str, deque] = {}
|
|
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(f"{self.log_prefix} __init__: All attributes initialized.")
|
|
|
|
logger.info(
|
|
f"{self.log_prefix} __init__: Attempting to start map worker thread..."
|
|
)
|
|
self._start_map_worker_thread()
|
|
|
|
logger.info(
|
|
f"{self.log_prefix} __init__: Processing initial_bbox_dict: {initial_bbox_dict}"
|
|
)
|
|
if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict):
|
|
self._target_bbox_input_gui = initial_bbox_dict.copy()
|
|
logger.info(
|
|
f"{self.log_prefix} __init__: Valid initial_bbox_dict provided. Requesting render for bbox."
|
|
)
|
|
self._request_map_render_for_bbox(
|
|
initial_bbox_dict, preserve_current_zoom_if_possible=False
|
|
)
|
|
elif not self.is_detail_map:
|
|
logger.warning(
|
|
f"{self.log_prefix} __init__ (MAIN MAP): 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"{self.log_prefix} __init__ (MAIN MAP): Using default config BBox. Requesting render."
|
|
)
|
|
self._request_map_render_for_bbox(
|
|
self._target_bbox_input_gui, preserve_current_zoom_if_possible=False
|
|
)
|
|
else:
|
|
logger.critical(
|
|
f"{self.log_prefix} __init__ (MAIN MAP): Default fallback BBox from config is invalid: {default_bbox_cfg}."
|
|
)
|
|
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"{self.log_prefix} __init__ (MAIN MAP): 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,
|
|
)
|
|
else:
|
|
logger.info(
|
|
f"{self.log_prefix} __init__ (DETAIL MAP): No initial_bbox_dict. Map will be blank until track data is provided."
|
|
)
|
|
self._display_placeholder_text("Detail Map\nAwaiting flight data...")
|
|
|
|
self._setup_event_bindings() # ORA QUESTO DOVREBBE FUNZIONARE
|
|
logger.info(f"{self.log_prefix} __init__: Event bindings set up.")
|
|
|
|
logger.info(
|
|
f"{self.log_prefix} __init__: Attempting to start GUI result processing..."
|
|
)
|
|
self._start_gui_result_processing()
|
|
|
|
logger.info(f">>> {self.log_prefix} __init__ FINISHED <<<")
|
|
|
|
def _start_map_worker_thread(self):
|
|
if self._map_worker_thread is not None and self._map_worker_thread.is_alive():
|
|
logger.warning(f"{self.log_prefix} 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=f"MapRenderWorker_detail_{self.is_detail_map}",
|
|
daemon=True,
|
|
)
|
|
self._map_worker_thread.start()
|
|
logger.info(f"{self.log_prefix} MapRenderWorker thread started successfully.")
|
|
|
|
def _map_render_worker_target(self):
|
|
worker_initial_settle_delay_seconds = 0.1
|
|
thread_name = threading.current_thread().name
|
|
logger.info(
|
|
f"{thread_name}: Target loop initiated, starting initial settle delay ({worker_initial_settle_delay_seconds}s)..."
|
|
)
|
|
|
|
if self._map_worker_stop_event.wait(
|
|
timeout=worker_initial_settle_delay_seconds
|
|
):
|
|
logger.info(
|
|
f"{thread_name}: Received stop signal during initial settle. Terminating worker target."
|
|
)
|
|
return
|
|
|
|
logger.info(
|
|
f"{thread_name}: Initial settle delay complete. Worker entering main request loop."
|
|
)
|
|
|
|
while not self._map_worker_stop_event.is_set():
|
|
request_data = None
|
|
request_id = -1
|
|
try:
|
|
request_data = self._map_render_request_queue.get(
|
|
timeout=MAP_WORKER_QUEUE_TIMEOUT_S
|
|
)
|
|
request_type = request_data.get("type")
|
|
request_id = request_data.get("request_id", -1)
|
|
logger.info(
|
|
f"{thread_name}: Dequeued request. Type: '{request_type}', ID: {request_id}"
|
|
)
|
|
|
|
if request_type == RENDER_REQUEST_TYPE_SHUTDOWN:
|
|
logger.info(
|
|
f"{thread_name}: Received RENDER_REQUEST_TYPE_SHUTDOWN. Exiting loop."
|
|
)
|
|
self._map_render_request_queue.task_done()
|
|
break
|
|
|
|
if request_type == RENDER_REQUEST_TYPE_MAP:
|
|
logger.info(
|
|
f"{thread_name}: Processing RENDER_REQUEST_TYPE_MAP for ID: {request_id}"
|
|
)
|
|
center_lat = request_data.get("center_lat")
|
|
center_lon = request_data.get("center_lon")
|
|
zoom = request_data.get("zoom")
|
|
canvas_w = request_data.get("canvas_width")
|
|
canvas_h = request_data.get("canvas_height")
|
|
|
|
if None in [center_lat, center_lon, zoom, canvas_w, canvas_h]:
|
|
logger.error(
|
|
f"{thread_name}: Missing critical parameters in request ID {request_id}. Aborting. Data: {request_data}"
|
|
)
|
|
error_payload = {
|
|
"request_id": request_id,
|
|
"photo_image": None,
|
|
"map_geo_bounds": None,
|
|
"error": "Worker: Missing critical render parameters.",
|
|
}
|
|
self._map_render_result_queue.put(error_payload)
|
|
self._map_render_request_queue.task_done()
|
|
continue
|
|
|
|
target_bbox = request_data.get("target_bbox")
|
|
flights_to_draw = request_data.get("flights", [])
|
|
tracks_to_draw = request_data.get("tracks", {})
|
|
max_track_pts_from_req = request_data.get(
|
|
"max_track_points", DEFAULT_MAX_TRACK_POINTS
|
|
)
|
|
|
|
photo_image_result, actual_map_bounds, error_message = (
|
|
self._execute_render_pipeline(
|
|
center_lat,
|
|
center_lon,
|
|
zoom,
|
|
canvas_w,
|
|
canvas_h,
|
|
target_bbox,
|
|
flights_to_draw,
|
|
tracks_to_draw,
|
|
max_track_pts_from_req,
|
|
)
|
|
)
|
|
|
|
if self._map_worker_stop_event.is_set():
|
|
logger.info(
|
|
f"{thread_name}: Stop event set after render pipeline for ID {request_id}. Discarding result."
|
|
)
|
|
break
|
|
|
|
result_payload = {
|
|
"request_id": request_id,
|
|
"photo_image": photo_image_result,
|
|
"map_geo_bounds": actual_map_bounds,
|
|
"error": error_message,
|
|
}
|
|
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:
|
|
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)
|
|
except queue.Full:
|
|
logger.error(
|
|
f"{thread_name}: Result queue full while trying to report worker unhandled exception."
|
|
)
|
|
except Exception as e_put_err:
|
|
logger.error(
|
|
f"{thread_name}: Error putting unhandled exception report to result queue: {e_put_err}"
|
|
)
|
|
if request_data:
|
|
try:
|
|
self._map_render_request_queue.task_done()
|
|
except ValueError:
|
|
pass
|
|
time.sleep(0.5)
|
|
logger.info(f"{thread_name}: Worker thread target loop finished.")
|
|
|
|
def _start_gui_result_processing(self):
|
|
if self._gui_after_id_result_processor:
|
|
try:
|
|
if self.canvas.winfo_exists():
|
|
self.canvas.after_cancel(self._gui_after_id_result_processor)
|
|
except Exception as e_cancel:
|
|
logger.warning(
|
|
f"{self.log_prefix} 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(f"{self.log_prefix} GUI result processing scheduled.")
|
|
else:
|
|
logger.warning(
|
|
f"{self.log_prefix} 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_config: int,
|
|
) -> Tuple[
|
|
Optional[ImageTk.PhotoImage],
|
|
Optional[Tuple[float, float, float, float]],
|
|
Optional[str],
|
|
]:
|
|
log_prefix_pipeline = (
|
|
f"{self.log_prefix} WorkerPipeline" # Usa il log_prefix dell'istanza
|
|
)
|
|
logger.debug(
|
|
f"{self.log_prefix} WorkerPipeline: Starting for Z{zoom_level}, Center ({center_lat:.4f},{center_lon:.4f}), Canvas {canvas_w}x{canvas_h}"
|
|
)
|
|
|
|
# >>> INIZIO BLOCCO DEBUG TRACCIA <<<
|
|
if self.is_detail_map and current_flights_to_display:
|
|
detail_icao = current_flights_to_display[
|
|
0
|
|
].icao24 # Assumiamo che ci sia un solo volo per la mappa dettaglio
|
|
track_for_icao_in_worker = flight_tracks.get(detail_icao)
|
|
logger.info(f"{log_prefix_pipeline}: DETAIL MAP for ICAO {detail_icao}.")
|
|
logger.info(
|
|
f"{log_prefix_pipeline}: - current_flights_to_display (live point): {current_flights_to_display}"
|
|
)
|
|
if track_for_icao_in_worker:
|
|
logger.info(
|
|
f"{log_prefix_pipeline}: - Track from flight_tracks (deque length): {len(track_for_icao_in_worker)}"
|
|
)
|
|
# Stampa i primi e ultimi punti della deque per un controllo
|
|
# if len(track_for_icao_in_worker) > 0:
|
|
# logger.info(f"{log_prefix_pipeline}: Track head: {list(track_for_icao_in_worker)[0]}")
|
|
# if len(track_for_icao_in_worker) > 1:
|
|
# logger.info(f"{log_prefix_pipeline}: Track tail: {list(track_for_icao_in_worker)[-1]}")
|
|
else:
|
|
logger.info(
|
|
f"{log_prefix_pipeline}: - No track found in flight_tracks for ICAO {detail_icao}."
|
|
)
|
|
# >>> FINE BLOCCO DEBUG TRACCIA <<<
|
|
|
|
if (
|
|
not PIL_IMAGE_LIB_AVAILABLE
|
|
or not MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
or map_utils.mercantile is None
|
|
or Image is None
|
|
or ImageDraw is None
|
|
or ImageTk is None
|
|
):
|
|
err_msg = f"{self.log_prefix} 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"{self.log_prefix} 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 = f"{self.log_prefix} WorkerPipeline: Failed to calculate canvas geographic BBox."
|
|
logger.error(err_msg)
|
|
return None, None, err_msg
|
|
|
|
tile_xy_ranges = get_tile_ranges_for_bbox(canvas_geo_bbox, zoom_level)
|
|
if not tile_xy_ranges:
|
|
err_msg = f"{self.log_prefix} 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(f"{self.log_prefix} WorkerPipeline: ", ""),
|
|
)
|
|
if ImageTk:
|
|
return ImageTk.PhotoImage(placeholder_img), None, err_msg
|
|
else:
|
|
return (
|
|
None,
|
|
None,
|
|
f"{self.log_prefix} WorkerPipeline: ImageTk unavailable for placeholder.",
|
|
)
|
|
except Exception as e_ph:
|
|
return (
|
|
None,
|
|
None,
|
|
f"{self.log_prefix} WorkerPipeline: Tile range error AND placeholder creation failed: {e_ph}",
|
|
)
|
|
|
|
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 = f"{self.log_prefix} WorkerPipeline: Failed to stitch map image from TileManager."
|
|
logger.error(err_msg)
|
|
return None, None, err_msg
|
|
|
|
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(
|
|
f"{self.log_prefix} WorkerPipeline: Could not determine actual stitched map geo bounds. Using canvas_geo_bbox as fallback."
|
|
)
|
|
actual_stitched_map_geo_bounds = canvas_geo_bbox
|
|
|
|
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)
|
|
|
|
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,
|
|
)
|
|
except Exception as e_bbox_draw_pipe:
|
|
logger.error(
|
|
f"{self.log_prefix} WorkerPipeline: Error drawing target BBox: {e_bbox_draw_pipe}",
|
|
exc_info=False,
|
|
)
|
|
|
|
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:
|
|
current_flight_track_deque = flight_tracks.get(
|
|
flight.icao24
|
|
)
|
|
map_drawing._draw_single_flight(
|
|
draw,
|
|
pixel_coords_f,
|
|
flight,
|
|
label_font,
|
|
track_deque=current_flight_track_deque,
|
|
current_map_geo_bounds=actual_stitched_map_geo_bounds,
|
|
current_stitched_map_pixel_shape=img_shape,
|
|
)
|
|
flights_drawn_count += 1
|
|
except Exception as e_flight_draw_pipe:
|
|
logger.error(
|
|
f"{self.log_prefix} WorkerPipeline: Error drawing flight {flight.icao24}: {e_flight_draw_pipe}",
|
|
exc_info=False,
|
|
)
|
|
logger.debug(
|
|
f"{self.log_prefix} WorkerPipeline: Drew {flights_drawn_count} flight markers and tracks."
|
|
)
|
|
|
|
try:
|
|
if ImageTk:
|
|
final_photo_image = ImageTk.PhotoImage(image_to_draw_on)
|
|
logger.debug(
|
|
f"{self.log_prefix} WorkerPipeline: Successfully created PhotoImage."
|
|
)
|
|
return final_photo_image, actual_stitched_map_geo_bounds, None
|
|
else:
|
|
return (
|
|
None,
|
|
actual_stitched_map_geo_bounds,
|
|
f"{self.log_prefix} WorkerPipeline: ImageTk module not available.",
|
|
)
|
|
except Exception as e_photo_pipe:
|
|
err_msg_photo = f"{self.log_prefix} 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(
|
|
f"{self.log_prefix} Canvas destroyed, stopping map render result processing."
|
|
)
|
|
self._gui_after_id_result_processor = None
|
|
return
|
|
|
|
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"{self.log_prefix} 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"{self.log_prefix} 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"{self.log_prefix} GUI ResultsProcessor: Received error from Worker (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"{self.log_prefix} 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
|
|
else:
|
|
logger.warning(
|
|
f"{self.log_prefix} GUI ResultsProcessor: Received invalid/empty result from worker for ReqID {request_id}. Error: '{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 not self.is_detail_map:
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "update_general_map_info"
|
|
):
|
|
logger.debug(
|
|
f"{self.log_prefix} GUI ResultsProcessor (MAIN MAP): Requesting controller to update general map info after ReqID {request_id}."
|
|
)
|
|
self.app_controller.update_general_map_info()
|
|
|
|
except queue.Empty:
|
|
pass
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"{self.log_prefix} 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
|
|
)
|
|
|
|
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"{self.log_prefix} 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(
|
|
f"{self.log_prefix} 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"{self.log_prefix} 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:
|
|
logger.error(
|
|
f"{self.log_prefix} Error deepcopying flights for render: {e}"
|
|
)
|
|
|
|
# >>> NUOVO BLOCCO DEBUG <<<
|
|
if self.is_detail_map and flights_copy:
|
|
current_detail_icao = flights_copy[0].icao24
|
|
logger.info(
|
|
f"{self.log_prefix} _request_map_render: BEFORE deepcopy, content of self.flight_tracks_gui for ICAO {current_detail_icao}: {self.flight_tracks_gui.get(current_detail_icao)}"
|
|
)
|
|
# >>> FINE NUOVO BLOCCO DEBUG <<<
|
|
|
|
tracks_copy = {}
|
|
try:
|
|
tracks_copy = copy.deepcopy(self.flight_tracks_gui)
|
|
# >>> NUOVO BLOCCO DEBUG <<<
|
|
if self.is_detail_map and flights_copy:
|
|
current_detail_icao = flights_copy[0].icao24
|
|
logger.info(
|
|
f"{self.log_prefix} _request_map_render: AFTER deepcopy, content of tracks_copy for ICAO {current_detail_icao}: {tracks_copy.get(current_detail_icao)}"
|
|
)
|
|
# >>> FINE NUOVO BLOCCO DEBUG <<<
|
|
except Exception as e_copy_t:
|
|
logger.error(
|
|
f"{self.log_prefix} Error deepcopying tracks for render: {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:
|
|
logger.error(
|
|
f"{self.log_prefix} Error deepcopying ensure_bbox for render: {e}"
|
|
)
|
|
elif self._target_bbox_input_gui:
|
|
try:
|
|
target_bbox_to_send = copy.deepcopy(self._target_bbox_input_gui)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"{self.log_prefix} Error deepcopying target_bbox_input for render: {e}"
|
|
)
|
|
|
|
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,
|
|
}
|
|
|
|
try:
|
|
self._clear_canvas_display_elements()
|
|
loading_text = f"Loading Map...\nZ{zoom_level} @ ({center_lat:.2f}, {center_lon:.2f})\nReqID: {current_request_id}"
|
|
self._display_placeholder_text(loading_text)
|
|
self._map_render_request_queue.put_nowait(request_payload)
|
|
logger.info(
|
|
f"{self.log_prefix} 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
|
|
except queue.Full:
|
|
logger.warning(
|
|
f"{self.log_prefix} Map render request queue FULL. Request ID {current_request_id} was DROPPED."
|
|
)
|
|
self._display_placeholder_text(
|
|
"Map Busy / Request Queue Full.\nPlease Try Action Again."
|
|
)
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"{self.log_prefix} GUI _request_map_render: Error queuing map render request ID {current_request_id}: {e}"
|
|
)
|
|
self._display_placeholder_text(
|
|
f"Error Queuing Map Request:\n{type(e).__name__}"
|
|
)
|
|
|
|
def _setup_event_bindings(self):
|
|
self.canvas.bind("<Configure>", self._on_canvas_resize)
|
|
self.canvas.bind("<ButtonPress-1>", self._on_left_button_press)
|
|
self.canvas.bind("<ButtonRelease-1>", self._on_left_button_release)
|
|
self.canvas.bind("<ButtonPress-3>", self._on_right_click)
|
|
self._drag_start_x_canvas: Optional[int] = None
|
|
self._drag_start_y_canvas: Optional[int] = None
|
|
self._is_left_button_pressed: bool = False
|
|
logger.debug(f"{self.log_prefix} 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.info(
|
|
f"{self.log_prefix} _on_canvas_resize: Canvas dimensions changed from {self.canvas_width}x{self.canvas_height} to {new_width}x{new_height}. Debouncing redraw."
|
|
)
|
|
if self._resize_debounce_job_id:
|
|
try:
|
|
if self.canvas.winfo_exists():
|
|
self.canvas.after_cancel(self._resize_debounce_job_id)
|
|
except Exception:
|
|
pass
|
|
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,
|
|
)
|
|
|
|
def _perform_resize_redraw(self, width: int, height: int):
|
|
self._resize_debounce_job_id = None
|
|
if not self.canvas.winfo_exists():
|
|
logger.warning(
|
|
f"{self.log_prefix} _perform_resize_redraw: Canvas does not exist. Aborting."
|
|
)
|
|
return
|
|
logger.info(
|
|
f"{self.log_prefix} _perform_resize_redraw: Executing debounced resize to {width}x{height}. Requesting new render."
|
|
)
|
|
self.canvas_width, self.canvas_height = width, 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
|
|
):
|
|
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(
|
|
f"{self.log_prefix} _perform_resize_redraw: Cannot redraw on resize - current map center/zoom not set."
|
|
)
|
|
self._display_placeholder_text("Map State Error\n(Cannot redraw on resize)")
|
|
|
|
def _on_left_button_press(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
logger.debug(
|
|
f"{self.log_prefix} _on_left_button_press: CanvasX={event.x}, CanvasY={event.y}"
|
|
)
|
|
self._drag_start_x_canvas, self._drag_start_y_canvas = event.x, event.y
|
|
self._is_left_button_pressed = True
|
|
|
|
def _on_left_button_release(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
logger.debug(
|
|
f"{self.log_prefix} Left Button Release: CanvasX={event.x}, CanvasY={event.y}"
|
|
)
|
|
|
|
if not self._is_left_button_pressed:
|
|
self._drag_start_x_canvas, self._drag_start_y_canvas = None, None
|
|
return
|
|
self._is_left_button_pressed = False
|
|
|
|
clicked_flight_icao: Optional[str] = None
|
|
if (
|
|
self._drag_start_x_canvas is not None
|
|
and self._drag_start_y_canvas is not None
|
|
):
|
|
drag_threshold_px = 10
|
|
dx = abs(event.x - self._drag_start_x_canvas)
|
|
dy = abs(event.y - self._drag_start_y_canvas)
|
|
is_click = dx < drag_threshold_px and dy < drag_threshold_px
|
|
|
|
if is_click:
|
|
logger.debug(
|
|
f"{self.log_prefix} Left Button Release: Detected as a map click."
|
|
)
|
|
if (
|
|
self._current_map_geo_bounds_gui is not None
|
|
and self._map_photo_image is not None
|
|
):
|
|
try:
|
|
map_pixel_shape = (
|
|
self._map_photo_image.width(),
|
|
self._map_photo_image.height(),
|
|
)
|
|
clicked_lon, clicked_lat = map_utils._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 ({self.log_prefix}) at Geo ({clicked_lat:.5f}, {clicked_lon:.5f})"
|
|
)
|
|
if not self.is_detail_map and self.app_controller:
|
|
if 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,
|
|
)
|
|
|
|
min_dist_sq_to_flight = float("inf")
|
|
flight_click_radius_px_sq = 15 * 15
|
|
with self._map_data_lock:
|
|
flights_on_map = (
|
|
self._current_flights_to_display_gui
|
|
)
|
|
if flights_on_map:
|
|
for flight in flights_on_map:
|
|
if (
|
|
flight.latitude is not None
|
|
and flight.longitude is not None
|
|
):
|
|
flight_px_coords = map_drawing._geo_to_pixel_on_unscaled_map(
|
|
flight.latitude,
|
|
flight.longitude,
|
|
self._current_map_geo_bounds_gui,
|
|
map_pixel_shape,
|
|
)
|
|
if flight_px_coords:
|
|
dist_sq = (
|
|
event.x - flight_px_coords[0]
|
|
) ** 2 + (
|
|
event.y - flight_px_coords[1]
|
|
) ** 2
|
|
if (
|
|
dist_sq < flight_click_radius_px_sq
|
|
and dist_sq < min_dist_sq_to_flight
|
|
):
|
|
min_dist_sq_to_flight = dist_sq
|
|
clicked_flight_icao = flight.icao24
|
|
if clicked_flight_icao:
|
|
logger.info(
|
|
f"Flight selected by click ({self.log_prefix}): {clicked_flight_icao}"
|
|
)
|
|
if hasattr(
|
|
self.app_controller,
|
|
"request_detailed_flight_info",
|
|
):
|
|
self.app_controller.request_detailed_flight_info(
|
|
clicked_flight_icao
|
|
)
|
|
elif not self.is_detail_map:
|
|
logger.info(
|
|
f"No specific flight selected by click on MAIN map. Clearing details."
|
|
)
|
|
if hasattr(
|
|
self.app_controller,
|
|
"request_detailed_flight_info",
|
|
):
|
|
self.app_controller.request_detailed_flight_info(
|
|
""
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"Failed to convert left click pixel ({event.x},{event.y}) to geo ({self.log_prefix})."
|
|
)
|
|
if (
|
|
not self.is_detail_map
|
|
and self.app_controller
|
|
and hasattr(
|
|
self.app_controller, "request_detailed_flight_info"
|
|
)
|
|
):
|
|
self.app_controller.request_detailed_flight_info("")
|
|
except Exception as e_click_proc:
|
|
logger.error(
|
|
f"Error during left click processing ({self.log_prefix}): {e_click_proc}",
|
|
exc_info=True,
|
|
)
|
|
if (
|
|
not self.is_detail_map
|
|
and self.app_controller
|
|
and hasattr(
|
|
self.app_controller, "request_detailed_flight_info"
|
|
)
|
|
):
|
|
self.app_controller.request_detailed_flight_info("")
|
|
else:
|
|
logger.warning(
|
|
f"Map context missing for left click ({self.log_prefix})."
|
|
)
|
|
if (
|
|
not self.is_detail_map
|
|
and self.app_controller
|
|
and hasattr(self.app_controller, "request_detailed_flight_info")
|
|
):
|
|
self.app_controller.request_detailed_flight_info("")
|
|
self._drag_start_x_canvas, self._drag_start_y_canvas = None, None
|
|
|
|
def _on_right_click(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
logger.debug(
|
|
f"{self.log_prefix} _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(
|
|
f"Map context missing for right click geo conversion ({self.log_prefix})."
|
|
)
|
|
return
|
|
try:
|
|
map_pixel_shape = (
|
|
self._map_photo_image.width(),
|
|
self._map_photo_image.height(),
|
|
)
|
|
geo_lon, geo_lat = map_utils._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 ({self.log_prefix}) at Geo ({geo_lat:.5f}, {geo_lon:.5f})"
|
|
)
|
|
if not self.is_detail_map and self.app_controller:
|
|
if 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 ({self.log_prefix})."
|
|
)
|
|
except Exception as e_rclick_convert:
|
|
logger.error(
|
|
f"Error during right click geo conversion ({self.log_prefix}): {e_rclick_convert}",
|
|
exc_info=True,
|
|
)
|
|
|
|
def set_max_track_points(self, length: int):
|
|
new_length = max(2, length)
|
|
if self.max_track_points != new_length:
|
|
logger.info(
|
|
f"{self.log_prefix}: Max track points updating from {self.max_track_points} to {new_length}"
|
|
)
|
|
with self._map_data_lock:
|
|
self.max_track_points = new_length
|
|
for icao in list(self.flight_tracks_gui.keys()):
|
|
track_deque = self.flight_tracks_gui.get(icao)
|
|
if track_deque:
|
|
new_deque_for_icao = deque(maxlen=self.max_track_points + 5)
|
|
start_index = max(0, len(track_deque) - self.max_track_points)
|
|
for i in range(start_index, len(track_deque)):
|
|
new_deque_for_icao.append(track_deque[i])
|
|
if not new_deque_for_icao:
|
|
if icao in self.flight_tracks_gui:
|
|
del self.flight_tracks_gui[icao]
|
|
else:
|
|
self.flight_tracks_gui[icao] = new_deque_for_icao
|
|
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
|
|
):
|
|
self._request_map_render(
|
|
self._current_center_lat_gui,
|
|
self._current_center_lon_gui,
|
|
self._current_zoom_gui,
|
|
)
|
|
|
|
def set_target_bbox(self, new_bbox_dict: Dict[str, float]):
|
|
logger.info(
|
|
f"{self.log_prefix} set_target_bbox (GUI): Received new target BBox: {new_bbox_dict}"
|
|
)
|
|
if new_bbox_dict and _is_valid_bbox_dict(new_bbox_dict):
|
|
with self._map_data_lock:
|
|
self._target_bbox_input_gui = new_bbox_dict.copy()
|
|
logger.debug(
|
|
f"{self.log_prefix} set_target_bbox: _target_bbox_input_gui updated. Requesting render."
|
|
)
|
|
self._request_map_render_for_bbox(
|
|
self._target_bbox_input_gui, preserve_current_zoom_if_possible=False
|
|
)
|
|
if not self.is_detail_map:
|
|
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"{self.log_prefix} set_target_bbox: Invalid/empty new_bbox_dict: {new_bbox_dict}. Clearing."
|
|
)
|
|
with self._map_data_lock:
|
|
self._target_bbox_input_gui = None
|
|
if (
|
|
self._current_center_lat_gui is not None
|
|
and self._current_center_lon_gui is not None
|
|
and self._current_zoom_gui is not None
|
|
):
|
|
self._request_map_render(
|
|
self._current_center_lat_gui,
|
|
self._current_center_lon_gui,
|
|
self._current_zoom_gui,
|
|
)
|
|
else:
|
|
self.clear_map_display()
|
|
if not self.is_detail_map:
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "update_bbox_gui_fields"
|
|
):
|
|
self.app_controller.update_bbox_gui_fields({})
|
|
|
|
def _request_map_render_for_bbox(
|
|
self,
|
|
target_bbox_dict: Dict[str, float],
|
|
preserve_current_zoom_if_possible: bool = False,
|
|
):
|
|
logger.debug(
|
|
f"{self.log_prefix} _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(
|
|
f"{self.log_prefix} _request_map_render_for_bbox called with invalid/no target BBox. Aborting."
|
|
)
|
|
if not self.is_detail_map:
|
|
self._display_placeholder_text("Invalid BBox for Map Render")
|
|
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_gui
|
|
if not preserve_current_zoom_if_possible or 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:
|
|
new_calc_zoom = min(zoom_w, zoom_h)
|
|
elif zoom_w is not None:
|
|
new_calc_zoom = zoom_w
|
|
elif zoom_h is not None:
|
|
new_calc_zoom = zoom_h
|
|
else:
|
|
new_calc_zoom = map_constants.DEFAULT_INITIAL_ZOOM
|
|
zoom_to_use = new_calc_zoom
|
|
logger.info(
|
|
f"{self.log_prefix} _request_map_render_for_bbox: Calculated new zoom_to_use: {zoom_to_use}"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"{self.log_prefix} _request_map_render_for_bbox: Could not calculate BBox dims or canvas size invalid. Using default zoom."
|
|
)
|
|
zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
|
|
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))
|
|
self._request_map_render(
|
|
view_center_lat,
|
|
view_center_lon,
|
|
zoom_to_use,
|
|
ensure_bbox_is_covered_dict=target_bbox_dict,
|
|
)
|
|
|
|
def _clear_canvas_display_elements(self):
|
|
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
|
|
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:
|
|
pass
|
|
finally:
|
|
self._placeholder_text_id = None
|
|
|
|
def _display_placeholder_text(self, text: str):
|
|
logger.debug(
|
|
f"{self.log_prefix} _display_placeholder_text: Displaying '{text[:50]}...'"
|
|
)
|
|
if not self.canvas.winfo_exists():
|
|
logger.warning(
|
|
f"{self.log_prefix} _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
|
|
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),
|
|
)
|
|
except tk.TclError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(
|
|
f"{self.log_prefix} _display_placeholder_text: Unexpected error: {e}",
|
|
exc_info=True,
|
|
)
|
|
|
|
def clear_map_display(self):
|
|
logger.info(
|
|
f"{self.log_prefix} clear_map_display: Clearing all map content and resetting view state."
|
|
)
|
|
self._clear_canvas_display_elements()
|
|
self._display_placeholder_text("Map Cleared / Awaiting Action")
|
|
with self._map_data_lock:
|
|
self._current_flights_to_display_gui = []
|
|
self.flight_tracks_gui.clear()
|
|
self._current_map_geo_bounds_gui = None
|
|
|
|
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"{self.log_prefix} clear_map_display: Requesting re-render of clean base map."
|
|
)
|
|
self._request_map_render(
|
|
self._current_center_lat_gui,
|
|
self._current_center_lon_gui,
|
|
self._current_zoom_gui,
|
|
ensure_bbox_is_covered_dict=self._target_bbox_input_gui,
|
|
)
|
|
elif not self.is_detail_map:
|
|
if self.app_controller and hasattr(
|
|
self.app_controller, "update_general_map_info"
|
|
):
|
|
self.app_controller.update_general_map_info()
|
|
|
|
def update_flights_on_map(self, flight_states: List[CanonicalFlightState]):
|
|
logger.info(
|
|
f"{self.log_prefix} update_flights_on_map (GUI): Received {len(flight_states)} flight states."
|
|
)
|
|
with self._map_data_lock:
|
|
self._current_flights_to_display_gui = flight_states
|
|
if not flight_states:
|
|
self.flight_tracks_gui.clear()
|
|
else:
|
|
current_time = time.time()
|
|
active_icao_this_update = set()
|
|
for state in flight_states:
|
|
if (
|
|
state.latitude is not None
|
|
and state.longitude is not None
|
|
and state.timestamp is not None
|
|
):
|
|
active_icao_this_update.add(state.icao24)
|
|
if state.icao24 not in self.flight_tracks_gui:
|
|
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
|
|
if icao not in active_icao_this_update:
|
|
last_point_time = track_deque[-1][2]
|
|
if current_time - last_point_time > self.max_track_age_seconds:
|
|
tracks_to_remove_gui.append(icao)
|
|
if tracks_to_remove_gui:
|
|
for icao in tracks_to_remove_gui:
|
|
if icao in self.flight_tracks_gui:
|
|
del self.flight_tracks_gui[icao]
|
|
|
|
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"{self.log_prefix} 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,
|
|
)
|
|
|
|
def get_current_map_info(self) -> Dict[str, Any]:
|
|
logger.debug(f"{self.log_prefix} get_current_map_info (GUI) called.")
|
|
map_size_km_w, map_size_km_h = None, None
|
|
if (
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE
|
|
and map_utils.pyproj
|
|
and self._current_map_geo_bounds_gui
|
|
):
|
|
try:
|
|
size_km_tuple = calculate_geographic_bbox_size_km(
|
|
self._current_map_geo_bounds_gui
|
|
)
|
|
if size_km_tuple:
|
|
map_size_km_w, map_size_km_h = size_km_tuple
|
|
except Exception as e_map_size_info:
|
|
logger.warning(
|
|
f"Error calculating current map geo size for info panel ({self.log_prefix}): {e_map_size_info}",
|
|
exc_info=False,
|
|
)
|
|
|
|
with self._map_data_lock:
|
|
flight_count = len(self._current_flights_to_display_gui)
|
|
target_bbox_copy = (
|
|
copy.deepcopy(self._target_bbox_input_gui)
|
|
if self._target_bbox_input_gui
|
|
else None
|
|
)
|
|
|
|
info = {
|
|
"center_lat": self._current_center_lat_gui,
|
|
"center_lon": self._current_center_lon_gui,
|
|
"zoom": self._current_zoom_gui,
|
|
"map_geo_bounds": self._current_map_geo_bounds_gui,
|
|
"target_bbox_input": target_bbox_copy,
|
|
"canvas_width": self.canvas_width,
|
|
"canvas_height": self.canvas_height,
|
|
"map_size_km_w": map_size_km_w,
|
|
"map_size_km_h": map_size_km_h,
|
|
"flight_count": flight_count,
|
|
}
|
|
logger.debug(
|
|
f"{self.log_prefix} 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
|
|
):
|
|
if self.is_detail_map:
|
|
logger.warning(
|
|
"show_map_context_menu_from_gui called on a detail map. Ignoring."
|
|
)
|
|
return
|
|
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(map_constants, "COORDINATE_DECIMAL_PLACES", 5)
|
|
context_menu.add_command(
|
|
label=f"Context @ {latitude:.{decimals}f},{longitude:.{decimals}f}",
|
|
state=tk.DISABLED,
|
|
)
|
|
context_menu.add_separator()
|
|
if self.app_controller:
|
|
if hasattr(self.app_controller, "recenter_map_at_coords"):
|
|
context_menu.add_command(
|
|
label="Center map here",
|
|
command=lambda: self.app_controller.recenter_map_at_coords(
|
|
latitude, longitude
|
|
),
|
|
)
|
|
if hasattr(self.app_controller, "set_bbox_around_coords"):
|
|
area_km_cfg_name = "DEFAULT_CLICK_AREA_SIZE_KM"
|
|
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.tk_popup(screen_x, screen_y)
|
|
except tk.TclError as e:
|
|
logger.warning(f"TclError showing MapCanvasManager context menu: {e}.")
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error creating/showing MapCanvasManager context menu: {e}",
|
|
exc_info=True,
|
|
)
|
|
|
|
def recenter_map_at_coords(self, lat: float, lon: float):
|
|
logger.info(
|
|
f"{self.log_prefix} 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
|
|
self._request_map_render(lat, lon, self._current_zoom_gui)
|
|
else:
|
|
logger.warning(
|
|
f"{self.log_prefix} 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
|
|
):
|
|
if self.is_detail_map:
|
|
logger.warning(
|
|
f"{self.log_prefix} set_bbox_around_coords called on a DETAIL map. This action is likely for the main map."
|
|
)
|
|
return
|
|
|
|
logger.info(
|
|
f"{self.log_prefix} set_bbox_around_coords (MAIN MAP): 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 map_utils.pyproj is None:
|
|
logger.error(
|
|
f"{self.log_prefix} set_bbox_around_coords: Cannot set BBox - pyproj library not available."
|
|
)
|
|
if (
|
|
self.app_controller
|
|
and hasattr(self.app_controller, "main_window")
|
|
and self.app_controller.main_window
|
|
and hasattr(self.app_controller.main_window, "show_error_message")
|
|
):
|
|
self.app_controller.main_window.show_error_message(
|
|
"Map Error", "Cannot calculate BBox: Geographic libraries missing."
|
|
)
|
|
return
|
|
try:
|
|
bbox_tuple_wesn = get_bounding_box_from_center_size(
|
|
center_lat, center_lon, area_size_km
|
|
)
|
|
if bbox_tuple_wesn:
|
|
bbox_dict = {
|
|
"lon_min": bbox_tuple_wesn[0],
|
|
"lat_min": bbox_tuple_wesn[1],
|
|
"lon_max": bbox_tuple_wesn[2],
|
|
"lat_max": bbox_tuple_wesn[3],
|
|
}
|
|
if _is_valid_bbox_dict(bbox_dict):
|
|
self.set_target_bbox(bbox_dict)
|
|
else:
|
|
logger.error(
|
|
f"{self.log_prefix} set_bbox_around_coords: Calculated BBox is invalid: {bbox_dict}."
|
|
)
|
|
if (
|
|
self.app_controller
|
|
and hasattr(self.app_controller, "main_window")
|
|
and self.app_controller.main_window
|
|
and hasattr(
|
|
self.app_controller.main_window, "show_error_message"
|
|
)
|
|
):
|
|
self.app_controller.main_window.show_error_message(
|
|
"Map Error", "Calculated BBox is invalid."
|
|
)
|
|
else:
|
|
logger.error(
|
|
f"{self.log_prefix} set_bbox_around_coords: Failed to calculate BBox around coords."
|
|
)
|
|
if (
|
|
self.app_controller
|
|
and hasattr(self.app_controller, "main_window")
|
|
and self.app_controller.main_window
|
|
and hasattr(self.app_controller.main_window, "show_error_message")
|
|
):
|
|
self.app_controller.main_window.show_error_message(
|
|
"Map Error", "Failed to calculate BBox around coordinates."
|
|
)
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"{self.log_prefix} set_bbox_around_coords: Unexpected error calculating BBox: {e}"
|
|
)
|
|
if (
|
|
self.app_controller
|
|
and hasattr(self.app_controller, "main_window")
|
|
and self.app_controller.main_window
|
|
and hasattr(self.app_controller.main_window, "show_error_message")
|
|
):
|
|
self.app_controller.main_window.show_error_message(
|
|
"Map Error", f"An unexpected error occurred calculating BBox: {e}"
|
|
)
|
|
|
|
def zoom_in_at_center(self):
|
|
logger.debug(
|
|
f"{self.log_prefix} 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(
|
|
f"{self.log_prefix} zoom_in_at_center: Cannot zoom in - current map state is not defined."
|
|
)
|
|
return
|
|
if not self.canvas.winfo_exists():
|
|
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)
|
|
if new_zoom != self._current_zoom_gui:
|
|
self._current_zoom_gui = new_zoom
|
|
self._request_map_render(
|
|
self._current_center_lat_gui, self._current_center_lon_gui, new_zoom
|
|
)
|
|
|
|
def zoom_out_at_center(self):
|
|
logger.debug(
|
|
f"{self.log_prefix} 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(
|
|
f"{self.log_prefix} zoom_out_at_center: Cannot zoom out - current map state is not defined."
|
|
)
|
|
return
|
|
if not self.canvas.winfo_exists():
|
|
return
|
|
new_zoom = max(map_constants.MIN_ZOOM_LEVEL, self._current_zoom_gui - 1)
|
|
if new_zoom != self._current_zoom_gui:
|
|
self._current_zoom_gui = new_zoom
|
|
self._request_map_render(
|
|
self._current_center_lat_gui, self._current_center_lon_gui, new_zoom
|
|
)
|
|
|
|
def pan_map_fixed_step(
|
|
self, direction: str, step_fraction: float = PAN_STEP_FRACTION
|
|
):
|
|
logger.debug(
|
|
f"{self.log_prefix} pan_map_fixed_step (GUI) called. Direction: {direction}"
|
|
)
|
|
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(
|
|
f"{self.log_prefix} pan_map_fixed_step: Cannot pan map - current map state not fully defined."
|
|
)
|
|
return
|
|
if (
|
|
not self.canvas.winfo_exists()
|
|
or not PYPROJ_MODULE_LOCALLY_AVAILABLE
|
|
or map_utils.pyproj is None
|
|
):
|
|
logger.warning(
|
|
f"{self.log_prefix} 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"{self.log_prefix} pan_map_fixed_step: Unknown pan direction: {direction}"
|
|
)
|
|
return
|
|
|
|
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(
|
|
f"{self.log_prefix} pan_map_fixed_step: Could not calculate valid resolution for panning. Cannot pan."
|
|
)
|
|
return
|
|
|
|
delta_meters_x = delta_x_pixels * res_m_px
|
|
delta_meters_y = -delta_y_pixels * res_m_px
|
|
geod = map_utils.pyproj.Geod(ellps="WGS84")
|
|
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
|
|
new_lon, _, _ = geod.fwd(
|
|
current_calc_lon, current_calc_lat, azimuth_lon, abs(delta_meters_x)
|
|
)
|
|
current_calc_lon = new_lon
|
|
if abs(delta_meters_y) > 1e-9:
|
|
azimuth_lat = 0.0 if delta_meters_y > 0 else 180.0
|
|
_, new_lat, _ = geod.fwd(
|
|
current_calc_lon, current_calc_lat, azimuth_lat, abs(delta_meters_y)
|
|
)
|
|
current_calc_lat = new_lat
|
|
|
|
MAX_MERCATOR_LAT = 85.05112878
|
|
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
|
|
if final_new_center_lon == -180 and current_calc_lon > 0:
|
|
final_new_center_lon = 180.0
|
|
|
|
self._current_center_lat_gui = final_new_center_lat
|
|
self._current_center_lon_gui = final_new_center_lon
|
|
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"{self.log_prefix} 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(
|
|
f"{self.log_prefix} center_map_and_fit_patch: Cannot fit patch - canvas not ready or invalid dimensions."
|
|
)
|
|
if (
|
|
not self.is_detail_map
|
|
and self.app_controller
|
|
and hasattr(self.app_controller, "main_window")
|
|
and self.app_controller.main_window
|
|
and hasattr(self.app_controller.main_window, "show_error_message")
|
|
):
|
|
self.app_controller.main_window.show_error_message(
|
|
"Map Error", "Canvas not ready to fit patch."
|
|
)
|
|
return
|
|
|
|
zoom_for_width = calculate_zoom_level_for_geographic_size(
|
|
center_lat,
|
|
patch_size_km * 1000,
|
|
self.canvas_width,
|
|
self.tile_manager.tile_size,
|
|
)
|
|
zoom_for_height = calculate_zoom_level_for_geographic_size(
|
|
center_lat,
|
|
patch_size_km * 1000,
|
|
self.canvas_height,
|
|
self.tile_manager.tile_size,
|
|
)
|
|
|
|
if zoom_for_width is None or zoom_for_height is None:
|
|
logger.error(
|
|
f"{self.log_prefix} center_map_and_fit_patch: Could not calculate zoom to fit patch. 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))
|
|
|
|
self._current_center_lat_gui = center_lat
|
|
self._current_center_lon_gui = center_lon
|
|
self._current_zoom_gui = new_zoom
|
|
self._request_map_render(center_lat, center_lon, new_zoom)
|
|
|
|
def shutdown_worker(self):
|
|
logger.info(f"{self.log_prefix} Shutdown worker requested.")
|
|
self._map_worker_stop_event.set()
|
|
|
|
if self._gui_after_id_result_processor and self.canvas.winfo_exists():
|
|
try:
|
|
self.canvas.after_cancel(self._gui_after_id_result_processor)
|
|
except Exception:
|
|
pass
|
|
self._gui_after_id_result_processor = None
|
|
|
|
try:
|
|
self._map_render_request_queue.put_nowait(
|
|
{"type": RENDER_REQUEST_TYPE_SHUTDOWN, "request_id": -999}
|
|
)
|
|
except queue.Full:
|
|
logger.warning(
|
|
f"{self.log_prefix} Worker request queue full, cannot send shutdown signal via queue."
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"{self.log_prefix} Error sending shutdown signal to worker queue: {e}"
|
|
)
|
|
|
|
if self._map_worker_thread and self._map_worker_thread.is_alive():
|
|
logger.info(f"{self.log_prefix} Waiting for map worker thread to join...")
|
|
self._map_worker_thread.join(timeout=2.0)
|
|
if self._map_worker_thread.is_alive():
|
|
logger.warning(
|
|
f"{self.log_prefix} Map worker thread did not join in time."
|
|
)
|
|
else:
|
|
logger.info(f"{self.log_prefix} Map worker thread joined successfully.")
|
|
self._map_worker_thread = None
|
|
|
|
while not self._map_render_request_queue.empty():
|
|
try:
|
|
self._map_render_request_queue.get_nowait()
|
|
self._map_render_request_queue.task_done()
|
|
except Exception:
|
|
break
|
|
while not self._map_render_result_queue.empty():
|
|
try:
|
|
self._map_render_result_queue.get_nowait()
|
|
self._map_render_result_queue.task_done()
|
|
except Exception:
|
|
break
|
|
logger.info(f"{self.log_prefix} Worker shutdown sequence complete.")
|