991 lines
54 KiB
Python
991 lines
54 KiB
Python
# flightmonitor/map/map_canvas_manager.py
|
|
|
|
import tkinter as tk
|
|
|
|
try:
|
|
from PIL import Image, ImageTk, ImageDraw, ImageFont
|
|
|
|
PIL_IMAGE_LIB_AVAILABLE = True
|
|
except ImportError:
|
|
Image = None
|
|
ImageTk = None
|
|
ImageDraw = None
|
|
ImageFont = None
|
|
PIL_IMAGE_LIB_AVAILABLE = False
|
|
import logging
|
|
logging.error("MapCanvasManager: Pillow not found. Map disabled.")
|
|
|
|
|
|
try:
|
|
import pyproj
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE = True
|
|
except ImportError:
|
|
pyproj = None
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE = False
|
|
import logging
|
|
logging.warning("MapCanvasManager: 'pyproj' not found. Calc impaired.")
|
|
|
|
try:
|
|
import mercantile
|
|
MERCANTILE_MODULE_LOCALLY_AVAILABLE = True
|
|
except ImportError:
|
|
mercantile = None
|
|
MERCANTILE_MODULE_LOCALLY_AVAILABLE = False
|
|
import logging
|
|
logging.error("MapCanvasManager: 'mercantile' not found. Conversions impaired.")
|
|
|
|
|
|
import math
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
|
|
from flightmonitor.map import map_constants
|
|
|
|
from flightmonitor.data import config as fm_config
|
|
from flightmonitor.data.common_models import CanonicalFlightState
|
|
|
|
from flightmonitor.map.map_services import BaseMapService, OpenStreetMapService
|
|
from flightmonitor.map.map_manager import MapTileManager
|
|
from flightmonitor.map.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
|
|
)
|
|
from . import map_drawing
|
|
|
|
try:
|
|
from ..utils.logger import get_logger
|
|
except ImportError:
|
|
import logging
|
|
get_logger = logging.getLogger
|
|
logging.warning("MapCanvasManager using fallback logger.")
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
CANVAS_SIZE_HARD_FALLBACK_PX = 800
|
|
MAP_TILE_CACHE_DIR_HARD_FALLBACK = "flightmonitor_tile_cache_fallback"
|
|
RESIZE_DEBOUNCE_DELAY_MS = 50
|
|
|
|
|
|
class MapCanvasManager:
|
|
|
|
def __init__(
|
|
self,
|
|
app_controller: Any,
|
|
tk_canvas: tk.Canvas,
|
|
initial_bbox_dict: Dict[str, float],
|
|
):
|
|
logger.info("Initializing MapCanvasManager...")
|
|
if not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
|
|
critical_msg = "MapCanvasManager critical dependencies missing: Pillow or Mercantile. Map disabled."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
|
|
self.app_controller = app_controller
|
|
self.canvas = tk_canvas
|
|
|
|
self.canvas_width = self.canvas.winfo_width()
|
|
if self.canvas_width <= 1:
|
|
self.canvas_width = getattr(fm_config, 'DEFAULT_CANVAS_WIDTH', CANVAS_SIZE_HARD_FALLBACK_PX)
|
|
|
|
self.canvas_height = self.canvas.winfo_height()
|
|
if self.canvas_height <= 1:
|
|
self.canvas_height = getattr(fm_config, 'DEFAULT_CANVAS_HEIGHT', CANVAS_SIZE_HARD_FALLBACK_PX)
|
|
|
|
if self.canvas_width <= 0 or self.canvas_height <= 0:
|
|
logger.critical(f"MapCanvasManager init with invalid canvas dims ({self.canvas_width}x{self.canvas_height}) after fallbacks.")
|
|
raise ValueError("Invalid canvas dimensions.")
|
|
|
|
|
|
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
|
|
self._map_photo_image: Optional[ImageTk.PhotoImage] = None
|
|
self._canvas_image_id: Optional[int] = None
|
|
|
|
self.map_service: BaseMapService = OpenStreetMapService()
|
|
cache_dir = getattr(fm_config, "MAP_TILE_CACHE_DIR", MAP_TILE_CACHE_DIR_HARD_FALLBACK)
|
|
self.tile_manager: MapTileManager = MapTileManager(
|
|
map_service=self.map_service,
|
|
cache_root_directory=cache_dir,
|
|
tile_pixel_size=self.map_service.tile_size,
|
|
)
|
|
logger.info(f"MapTileManager initialized for service '{self.tile_manager.service_identifier_name}'.")
|
|
|
|
|
|
self._target_bbox_input: Optional[Dict[str, float]] = None
|
|
self._active_api_bbox_for_flights: Optional[Dict[str, float]] = None # Let controller manage this state for data fetching
|
|
self._current_flights_to_display: List[CanonicalFlightState] = []
|
|
|
|
self._resize_debounce_job_id: Optional[str] = None
|
|
self._zoom_debounce_job_id: Optional[str] = None
|
|
|
|
|
|
if initial_bbox_dict and _is_valid_bbox_dict(initial_bbox_dict):
|
|
self._target_bbox_input = initial_bbox_dict.copy()
|
|
self.update_map_view_for_bbox(initial_bbox_dict, preserve_current_zoom_if_possible=False)
|
|
else:
|
|
logger.warning(f"Invalid initial_bbox_dict: {initial_bbox_dict}. Using default fallback.")
|
|
default_bbox = {
|
|
"lat_min": fm_config.DEFAULT_BBOX_LAT_MIN, "lon_min": fm_config.DEFAULT_BBOX_LON_MIN,
|
|
"lat_max": fm_config.DEFAULT_BBOX_LAT_MAX, "lon_max": fm_config.DEFAULT_BBOX_LON_MAX,
|
|
}
|
|
if _is_valid_bbox_dict(default_bbox):
|
|
self._target_bbox_input = default_bbox.copy()
|
|
self.set_target_bbox(default_bbox)
|
|
else:
|
|
logger.critical(f"Default fallback BBox invalid: {default_bbox}. Cannot init map view.")
|
|
self._target_bbox_input = None
|
|
self._current_center_lat = None
|
|
self._current_center_lon = None
|
|
self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM
|
|
self._current_map_geo_bounds = None
|
|
self.clear_map_display()
|
|
if self.app_controller and hasattr(self.app_controller, "update_general_map_info"):
|
|
self.app_controller.update_general_map_info()
|
|
|
|
|
|
self._setup_event_bindings()
|
|
logger.info(f"MapCanvasManager initialized for canvas size {self.canvas_width}x{self.canvas_height}.")
|
|
|
|
|
|
def _setup_event_bindings(self):
|
|
self.canvas.bind("<Configure>", self._on_canvas_resize)
|
|
self.canvas.bind("<MouseWheel>", self._on_mouse_wheel_windows_macos)
|
|
self.canvas.bind("<Button-4>", self._on_mouse_wheel_linux)
|
|
self.canvas.bind("<Button-5>", self._on_mouse_wheel_linux)
|
|
self.canvas.bind("<ButtonPress-1>", self._on_mouse_button_press)
|
|
self.canvas.bind("<B1-Motion>", self._on_mouse_drag)
|
|
self.canvas.bind("<ButtonRelease-1>", self._on_mouse_button_release)
|
|
self.canvas.bind("<ButtonPress-3>", self._on_right_click)
|
|
self._drag_start_x_canvas: Optional[int] = None
|
|
self._drag_start_y_canvas: Optional[int] = None
|
|
self._drag_start_center_lon: Optional[float] = None
|
|
self._drag_start_center_lat: Optional[float] = None
|
|
self._is_dragging: bool = False
|
|
|
|
|
|
def _on_canvas_resize(self, event: tk.Event):
|
|
new_width = event.width
|
|
new_height = event.height
|
|
|
|
if new_width > 1 and new_height > 1 and (self.canvas_width != new_width or self.canvas_height != new_height):
|
|
if self._resize_debounce_job_id:
|
|
try: self.canvas.after_cancel(self._resize_debounce_job_id)
|
|
except tk.TclError: pass
|
|
except Exception as e: logger.error(f"Error cancelling resize redraw job: {e}", exc_info=False)
|
|
finally: self._resize_debounce_job_id = None
|
|
|
|
|
|
if self._zoom_debounce_job_id:
|
|
try: self.canvas.after_cancel(self._zoom_debounce_job_id)
|
|
except tk.TclError: pass
|
|
except Exception as e: logger.error(f"Error cancelling zoom job on resize: {e}", exc_info=False)
|
|
finally: self._zoom_debounce_job_id = None
|
|
|
|
|
|
self._resize_debounce_job_id = self.canvas.after(
|
|
RESIZE_DEBOUNCE_DELAY_MS,
|
|
self._perform_resize_redraw,
|
|
new_width,
|
|
new_height
|
|
)
|
|
|
|
|
|
def _perform_resize_redraw(self, width: int, height: int):
|
|
self._resize_debounce_job_id = None
|
|
|
|
if not self.canvas.winfo_exists(): return
|
|
|
|
logger.info(f"Performing debounced resize redraw for dimensions {width}x{height}.")
|
|
self.canvas_width = width
|
|
self.canvas_height = 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=False
|
|
)
|
|
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,
|
|
ensure_bbox_is_covered_dict=None
|
|
)
|
|
else:
|
|
logger.warning("No valid BBox or geo center after resize. Resetting to default view.")
|
|
default_bbox = {
|
|
"lat_min": fm_config.DEFAULT_BBOX_LAT_MIN, "lon_min": fm_config.DEFAULT_BBOX_LON_MIN,
|
|
"lat_max": fm_config.DEFAULT_BBOX_LAT_MAX, "lon_max": fm_config.DEFAULT_BBOX_LON_MAX,
|
|
}
|
|
if _is_valid_bbox_dict(default_bbox):
|
|
self._target_bbox_input = default_bbox.copy()
|
|
self.set_target_bbox(default_bbox)
|
|
else:
|
|
logger.critical("Default fallback BBox invalid after resize. Cannot set map view.")
|
|
self._target_bbox_input = None
|
|
self.clear_map_display()
|
|
|
|
|
|
def set_target_bbox(self, new_bbox_dict: Dict[str, float]):
|
|
logger.info(f"MapCanvasManager: New target BBox requested: {new_bbox_dict}")
|
|
if new_bbox_dict and _is_valid_bbox_dict(new_bbox_dict):
|
|
self._target_bbox_input = new_bbox_dict.copy()
|
|
self.update_map_view_for_bbox(self._target_bbox_input, preserve_current_zoom_if_possible=False)
|
|
|
|
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, ensure_bbox_is_covered_dict=None)
|
|
else:
|
|
self.clear_map_display()
|
|
|
|
if self.app_controller and hasattr(self.app_controller, "update_general_map_info"):
|
|
self.app_controller.update_general_map_info()
|
|
|
|
|
|
def update_map_view_for_bbox(
|
|
self,
|
|
target_bbox_dict: Dict[str, float],
|
|
preserve_current_zoom_if_possible: bool = False,
|
|
):
|
|
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 = (lat_min + lat_max) / 2.0
|
|
view_center_lon = (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
|
|
|
|
|
|
logger.info(f"Updating map view for target BBox. Center: ({view_center_lat:.4f}, {view_center_lon:.4f}), Preserve zoom: {preserve_current_zoom_if_possible}")
|
|
|
|
zoom_to_use = self._current_zoom
|
|
|
|
if not preserve_current_zoom_if_possible:
|
|
calculated_zoom_for_target_bbox = map_constants.DEFAULT_INITIAL_ZOOM
|
|
|
|
current_canvas_width = self.canvas_width
|
|
current_canvas_height = self.canvas_height
|
|
|
|
if current_canvas_width <= 0 or current_canvas_height <= 0:
|
|
current_canvas_width = self.canvas.winfo_width()
|
|
current_canvas_height = self.canvas.winfo_height()
|
|
|
|
|
|
if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None:
|
|
try:
|
|
geod = pyproj.Geod(ellps="WGS84")
|
|
center_lat_for_geod = max(-89.9, min(89.9, view_center_lat))
|
|
|
|
_, _, width_m = geod.inv(lon_min, center_lat_for_geod, lon_max, center_lat_for_geod)
|
|
width_m = abs(width_m)
|
|
|
|
center_lon_for_geod = max(-179.9, min(179.9, view_center_lon))
|
|
_, _, height_m = geod.inv(center_lon_for_geod, lat_min, center_lon_for_geod, lat_max)
|
|
height_m = abs(height_m)
|
|
|
|
|
|
if current_canvas_width <= 0 or current_canvas_height <= 0:
|
|
logger.warning(f"Canvas dims zero/invalid ({current_canvas_width}x{current_canvas_height}). Cannot calc zoom to fit BBox. Using default.")
|
|
zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
else:
|
|
req_res_h = float('inf');
|
|
if current_canvas_height > 0 and height_m > 1e-9: req_res_h = height_m / current_canvas_height
|
|
req_res_w = float('inf');
|
|
resolution_at_center_lat = calculate_meters_per_pixel(center_lat_for_geod, self._current_zoom if self._current_zoom is not None else map_constants.DEFAULT_INITIAL_ZOOM, self.tile_manager.tile_size)
|
|
if resolution_at_center_lat is not None and current_canvas_width > 0 and width_m > 1e-9 and resolution_at_center_lat > 1e-9:
|
|
# Estimate the required resolution based on the BBox width and canvas width, adjusted for latitude scale
|
|
# This is a simplified estimate. A more accurate method involves projecting the BBox corners.
|
|
# For now, assume resolution at center lat is representative.
|
|
req_res_w = width_m / current_canvas_width # This is the ground distance per pixel at center lat needed to fit width
|
|
|
|
|
|
target_res_m_px = float('inf');
|
|
if req_res_h < float('inf') and req_res_w < float('inf'):
|
|
# Take the maximum resolution needed to fit both dimensions
|
|
target_res_m_px = max(req_res_h, req_res_w)
|
|
elif req_res_h < float('inf'): target_res_m_px = req_res_h
|
|
elif req_res_w < float('inf'): target_res_m_px = req_res_w
|
|
else: logger.warning("Could not determine valid target res. Using default zoom."); target_res_m_px = 0
|
|
|
|
|
|
if target_res_m_px > 1e-9 and math.isfinite(target_res_m_px) and resolution_at_center_lat is not None and resolution_at_center_lat > 1e-9:
|
|
# Calculate required zoom based on target resolution and resolution at zoom 0
|
|
# Resolution at zoom 0 (equator): EARTH_CIRCUMFERENCE_METERS / TILE_PIXEL_SIZE
|
|
# Resolution at zoom Z (latitude): (EARTH_CIRCUMFERENCE_METERS * cos(lat)) / (TILE_PIXEL_SIZE * 2^Z)
|
|
# req_res = res_at_0 * cos(lat) / 2^Z
|
|
# 2^Z = res_at_0 * cos(lat) / req_res
|
|
# Z = log2(res_at_0 * cos(lat) / req_res)
|
|
|
|
res_at_zoom_0_equator = (40075016.686) / self.tile_manager.tile_size
|
|
clamped_center_lat_for_cos = max(-85.05, min(85.05, view_center_lat))
|
|
cos_lat = math.cos(math.radians(clamped_center_lat_for_cos))
|
|
|
|
if cos_lat > 1e-9 and target_res_m_px > 1e-9:
|
|
term = (res_at_zoom_0_equator * cos_lat) / target_res_m_px
|
|
if term > 1e-9:
|
|
precise_zoom = math.log2(term)
|
|
calculated_zoom_for_target_bbox = int(round(precise_zoom))
|
|
max_zoom_limit = 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(calculated_zoom_for_target_bbox, max_zoom_limit))
|
|
# Adjust zoom down if the calculated bounds at this zoom are still smaller than the target BBox
|
|
# This could happen due to tile alignment/rounding.
|
|
# A more complex check would be needed here. For now, rely on the BBox being ensured coverage.
|
|
logger.info(f"Calc zoom {zoom_to_use} to fit BBox (precise {precise_zoom:.2f}).")
|
|
else: logger.warning(f"Cannot calc zoom: log2 term non-positive. Using default ({map_constants.DEFAULT_INITIAL_ZOOM})."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
else: logger.warning(f"Cannot calc zoom: cosine/req_res problematic. Using default ({map_constants.DEFAULT_INITIAL_ZOOM})."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
else: logger.warning(f"Cannot calc zoom: target res invalid ({target_res_m_px:.2e}). Using default ({map_constants.DEFAULT_INITIAL_ZOOM})."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error calculating zoom for BBox: {e}. Using default zoom.", exc_info=True); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
else: logger.warning("Pyproj not available. Using default zoom."); zoom_to_use = map_constants.DEFAULT_INITIAL_ZOOM
|
|
|
|
|
|
bbox_to_ensure = target_bbox_dict # Always ensure the *original requested* BBox is covered when setting view for BBox.
|
|
self.recenter_and_redraw(view_center_lat, view_center_lon, zoom_to_use, ensure_bbox_is_covered_dict=bbox_to_ensure)
|
|
|
|
|
|
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 = center_lat
|
|
self._current_center_lon = center_lon
|
|
max_zoom_limit = 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_limit))
|
|
|
|
current_canvas_width = self.canvas_width
|
|
current_canvas_height = self.canvas_height
|
|
|
|
|
|
if current_canvas_width <= 0 or current_canvas_height <= 0:
|
|
logger.error(f"Canvas dims invalid ({current_canvas_width}x{current_canvas_height}). Cannot redraw.")
|
|
self._clear_canvas_display(); return
|
|
|
|
|
|
map_fetch_geo_bounds_for_tiles_tuple: Optional[Tuple[float, float, float, float]] = None
|
|
|
|
# Calculate the geographic area that the *canvas* will cover at the target center/zoom
|
|
canvas_fill_bbox = calculate_geographic_bbox_from_pixel_size_and_zoom(
|
|
self._current_center_lat, self._current_center_lon, current_canvas_width, current_canvas_height, self._current_zoom, self.tile_manager.tile_size,
|
|
)
|
|
|
|
# The area to fetch tiles for should be based on the canvas bounds.
|
|
if canvas_fill_bbox:
|
|
map_fetch_geo_bounds_for_tiles_tuple = canvas_fill_bbox
|
|
logger.debug(f"Tile fetching will be based on canvas_fill_bbox: {map_fetch_geo_bounds_for_tiles_tuple} at zoom {self._current_zoom}")
|
|
|
|
if ensure_bbox_is_covered_dict and _is_valid_bbox_dict(ensure_bbox_is_covered_dict):
|
|
user_bb = ensure_bbox_is_covered_dict
|
|
# Verification: check if user_bb is contained in canvas_fill_bbox (with tolerance)
|
|
# This check is useful during BBox fitting to see if the calculated zoom/center worked.
|
|
cf_w, cf_s, cf_e, cf_n = canvas_fill_bbox
|
|
contained = (
|
|
user_bb["lon_min"] >= cf_w - 1e-5 and
|
|
user_bb["lat_min"] >= cf_s - 1e-5 and
|
|
user_bb["lon_max"] <= cf_e + 1e-5 and
|
|
user_bb["lat_max"] <= cf_n + 1e-5
|
|
)
|
|
if not contained:
|
|
logger.warning(f"User BBox {user_bb} may not be fully contained within calculated canvas_fill_bbox {canvas_fill_bbox} at zoom {self._current_zoom}. Some parts might be clipped or outside the exact fetched tile area.")
|
|
else:
|
|
logger.debug(f"User BBox {user_bb} appears to be contained within canvas_fill_bbox {canvas_fill_bbox}.")
|
|
else:
|
|
logger.error("Failed to calculate canvas_fill_bbox. Cannot determine fetch bounds.")
|
|
self._clear_canvas_display(); return
|
|
|
|
if map_fetch_geo_bounds_for_tiles_tuple is None:
|
|
logger.critical("map_fetch_geo_bounds_for_tiles_tuple is None after BBox logic. Aborting redraw.")
|
|
self._clear_canvas_display(); return
|
|
|
|
|
|
tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bounds_for_tiles_tuple, self._current_zoom)
|
|
if not tile_xy_ranges:
|
|
logger.error(f"Failed to get tile ranges for {map_fetch_geo_bounds_for_tiles_tuple} 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 ImageFont is not None:
|
|
try:
|
|
placeholder_img = Image.new("RGB", (current_canvas_width, current_canvas_height), map_constants.DEFAULT_PLACEHOLDER_COLOR_RGB)
|
|
if ImageDraw:
|
|
draw = ImageDraw.Draw(placeholder_img)
|
|
self.tile_manager._draw_text_on_placeholder(draw, placeholder_img.size, "Map Error\nCannot get tiles.")
|
|
if self.canvas.winfo_exists():
|
|
self._clear_canvas_display()
|
|
self._map_photo_image = ImageTk.PhotoImage(placeholder_img)
|
|
self._canvas_image_id = self.canvas.create_image(0, 0, anchor=tk.NW, image=self._map_photo_image)
|
|
logger.debug("Drew tile range error placeholder.")
|
|
else: logger.warning("Canvas gone, cannot draw tile range error placeholder.")
|
|
except Exception as e_placeholder:
|
|
logger.error(f"Failed to draw tile range error placeholder: {e_placeholder}", exc_info=True)
|
|
return
|
|
|
|
|
|
logger.debug(f"Tile ranges for current view: X={tile_xy_ranges[0]}, Y={tile_xy_ranges[1]}")
|
|
|
|
stitched_map_pil = self.tile_manager.stitch_map_image(self._current_zoom, tile_xy_ranges[0], tile_xy_ranges[1])
|
|
|
|
if not stitched_map_pil:
|
|
logger.error("Failed to stitch map image.")
|
|
self._clear_canvas_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 for display info.")
|
|
self._current_map_geo_bounds = map_fetch_geo_bounds_for_tiles_tuple # Use fetch bounds as fallback
|
|
|
|
|
|
logger.debug(f"Actual stitched map geo bounds: {self._current_map_geo_bounds}")
|
|
self._map_pil_image = stitched_map_pil
|
|
|
|
if self._map_pil_image and PIL_IMAGE_LIB_AVAILABLE:
|
|
self._redraw_canvas_content()
|
|
# MODIFIED: Call update_general_map_info after redraw.
|
|
# WHY: To update the info panel with the new map bounds, center, zoom.
|
|
# HOW: Added the call.
|
|
if self.app_controller and hasattr(self.app_controller, "update_general_map_info"):
|
|
self.app_controller.update_general_map_info()
|
|
# MODIFIED: Call update_general_map_info even if PIL image not available, to update with N/A.
|
|
# WHY: Keep info panel updated even on failure.
|
|
# HOW: Added the call in the else branch.
|
|
elif 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.")
|
|
|
|
# MODIFIED: Use the correct PIL availability flag.
|
|
# WHY: Use the correct flag defined in this module.
|
|
# HOW: Changed the variable name.
|
|
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
|
|
|
|
logger.debug("Clearing previous canvas display.")
|
|
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 to draw overlays on. Canvas cleared, skipping overlay drawing.")
|
|
return
|
|
|
|
logger.debug("Base map image and geo bounds available. Proceeding with overlay drawing.")
|
|
|
|
if Image: image_to_draw_on = self._map_pil_image.copy()
|
|
else:
|
|
logger.error("Pillow Image class missing during image copy. Cannot draw overlays.")
|
|
return
|
|
|
|
image_to_draw_on_pixel_shape = image_to_draw_on.size
|
|
|
|
|
|
if self._target_bbox_input and _is_valid_bbox_dict(self._target_bbox_input):
|
|
logger.debug(f"Drawing target BBox: {self._target_bbox_input}")
|
|
user_bbox_to_draw_wesn = (self._target_bbox_input["lon_min"], self._target_bbox_input["lat_min"], self._target_bbox_input["lon_max"], self._target_bbox_input["lat_max"])
|
|
try:
|
|
image_to_draw_on = map_drawing.draw_area_bounding_box(
|
|
image_to_draw_on,
|
|
user_bbox_to_draw_wesn,
|
|
self._current_map_geo_bounds,
|
|
image_to_draw_on_pixel_shape,
|
|
color=map_constants.AREA_BOUNDARY_COLOR,
|
|
thickness=map_constants.AREA_BOUNDARY_THICKNESS_PX,
|
|
)
|
|
logger.debug("Target BBox drawn.")
|
|
except Exception as e: logger.error(f"Error drawing target BBox: {e}", exc_info=False)
|
|
|
|
|
|
flights_drawn_count = 0
|
|
logger.debug(f"Drawing {len(self._current_flights_to_display)} flights on map.")
|
|
|
|
if self._current_flights_to_display:
|
|
if ImageDraw is not None:
|
|
draw = ImageDraw.Draw(image_to_draw_on)
|
|
current_zoom_for_font = self._current_zoom if self._current_zoom is not None else map_constants.DEM_TILE_LABEL_BASE_ZOOM
|
|
label_font = map_drawing._load_label_font(map_constants.DEM_TILE_LABEL_BASE_FONT_SIZE + (current_zoom_for_font - map_constants.DEM_TILE_LABEL_BASE_ZOOM))
|
|
if label_font is None:
|
|
logger.warning("Could not load label font. Flight labels will not be drawn.")
|
|
|
|
|
|
for flight in self._current_flights_to_display:
|
|
pixel_coords = map_drawing._geo_to_pixel_on_unscaled_map(
|
|
flight.latitude,
|
|
flight.longitude,
|
|
self._current_map_geo_bounds,
|
|
image_to_draw_on_pixel_shape,
|
|
)
|
|
|
|
if pixel_coords:
|
|
try:
|
|
map_drawing._draw_single_flight(draw=draw, pixel_coords=pixel_coords, flight_state=flight, label_font=label_font)
|
|
flights_drawn_count += 1
|
|
except Exception as e:
|
|
logger.error(f"Error drawing flight {flight.icao24} (via MapDrawing helper): {e}", exc_info=False)
|
|
elif flight.latitude is not None and flight.longitude is not None:
|
|
logger.debug(f"Skipping draw for flight {flight.icao24}: Geo ({flight.latitude:.4f}, {flight.longitude:.4f}) could not be converted to pixel.")
|
|
|
|
|
|
else: logger.warning("ImageDraw not available, skipping drawing flights.")
|
|
|
|
logger.debug(f"Finished drawing flights. Total drawn: {flights_drawn_count}")
|
|
|
|
|
|
try:
|
|
if ImageTk:
|
|
self._map_photo_image = ImageTk.PhotoImage(image_to_draw_on)
|
|
logger.debug("Created PhotoImage from updated PIL image.")
|
|
else:
|
|
logger.error("Pillow ImageTk missing. Cannot create PhotoImage for canvas.")
|
|
self._clear_canvas_display(); return
|
|
except Exception as e:
|
|
logger.error(f"Failed to create PhotoImage from updated PIL image: {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)
|
|
logger.debug("Canvas redrawn with new image item.")
|
|
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, skipping final image draw.")
|
|
|
|
|
|
if self.app_controller and hasattr(self.app_controller, "update_general_map_info"):
|
|
try:
|
|
# MODIFIED: Call update_general_map_info after drawing is complete.
|
|
# WHY: To update the info panel with the latest map state including flight count.
|
|
# HOW: Moved the call here.
|
|
self.app_controller.update_general_map_info()
|
|
logger.debug("Requested general map info update via controller after redraw.")
|
|
except Exception as e_update_info:
|
|
logger.error(f"Error requesting general map info update via controller after redraw: {e_update_info}", exc_info=False)
|
|
|
|
|
|
|
|
def _clear_canvas_display(self):
|
|
logger.debug("Clearing main canvas image item.")
|
|
if self._canvas_image_id is not None and self.canvas.winfo_exists():
|
|
try: self.canvas.delete(self._canvas_image_id)
|
|
except tk.TclError: logger.warning("TclError deleting canvas item. Item/canvas gone.")
|
|
except Exception as e: logger.error(f"Error deleting canvas item {self._canvas_image_id}: {e}", exc_info=False)
|
|
finally: self._canvas_image_id = None
|
|
self._map_photo_image = None
|
|
logger.debug("Main canvas image item cleared.")
|
|
|
|
|
|
def clear_map_display(self):
|
|
logger.info("MapCanvasManager: Clearing all map content.")
|
|
self._clear_canvas_display()
|
|
self._map_pil_image = None
|
|
self._current_flights_to_display = []
|
|
self._current_map_geo_bounds = None
|
|
# Don't reset zoom or center unless explicitly requested (e.g. new BBox)
|
|
# self._current_zoom = map_constants.DEFAULT_INITIAL_ZOOM
|
|
# self._current_center_lat = None
|
|
# self._current_center_lon = None
|
|
self._target_bbox_input = None # Clear target BBox when clearing display
|
|
|
|
|
|
if self.canvas.winfo_exists():
|
|
try: self.canvas.delete("placeholder_text")
|
|
except tk.TclError: logger.debug("TclError clearing placeholder tag.")
|
|
except Exception as e: logger.error(f"Error clearing placeholder tag: {e}", exc_info=False)
|
|
|
|
|
|
# MODIFIED: Call update_general_map_info after clearing map display.
|
|
# WHY: To update the info panel to reflect that the map is cleared (N/A values).
|
|
# HOW: Added the call.
|
|
if self.app_controller and hasattr(self.app_controller, "update_general_map_info"): self.app_controller.update_general_map_info()
|
|
|
|
|
|
def _on_mouse_wheel_windows_macos(self, event: tk.Event):
|
|
zoom_direction = 1 if event.delta > 0 else -1
|
|
self._handle_zoom(zoom_direction, event.x, event.y)
|
|
|
|
def _on_mouse_wheel_linux(self, event: tk.Event):
|
|
zoom_direction = 0
|
|
if event.num == 4: zoom_direction = 1
|
|
elif event.num == 5: zoom_direction = -1
|
|
if zoom_direction != 0: self._handle_zoom(zoom_direction, event.x, event.y)
|
|
|
|
|
|
def _handle_zoom(self, zoom_direction: int, canvas_x: int, canvas_y: int):
|
|
if self._current_zoom is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
|
|
logger.warning("Map context/libs missing. Cannot handle zoom.")
|
|
return
|
|
|
|
# Cancel any existing debounced zoom job
|
|
if self._zoom_debounce_job_id:
|
|
try:
|
|
self.canvas.after_cancel(self._zoom_debounce_job_id)
|
|
except tk.TclError: pass
|
|
except Exception as e: logger.error(f"Error cancelling zoom job: {e}", exc_info=False)
|
|
finally: self._zoom_debounce_job_id = None
|
|
|
|
|
|
# Cancel any pending resize redraw job, as zoom implies a new view calculation.
|
|
if self._resize_debounce_job_id:
|
|
try: self.canvas.after_cancel(self._resize_debounce_job_id)
|
|
except tk.TclError: pass
|
|
except Exception as e: logger.error(f"Error cancelling resize job on zoom: {e}", exc_info=False)
|
|
finally: self._resize_debounce_job_id = None
|
|
|
|
|
|
# Schedule the actual zoom operation after a delay
|
|
# Store zoom parameters to be used by _perform_zoom_redraw
|
|
self._pending_zoom_direction = zoom_direction
|
|
self._pending_zoom_canvas_x = canvas_x
|
|
self._pending_zoom_canvas_y = canvas_y
|
|
|
|
self._zoom_debounce_job_id = self.canvas.after(
|
|
RESIZE_DEBOUNCE_DELAY_MS, # Use the same debounce delay as resize for consistency
|
|
self._perform_zoom_redraw
|
|
)
|
|
|
|
|
|
def _perform_zoom_redraw(self):
|
|
self._zoom_debounce_job_id = None
|
|
|
|
if not self.canvas.winfo_exists(): return
|
|
|
|
zoom_direction = self._pending_zoom_direction
|
|
canvas_x = self._pending_zoom_canvas_x
|
|
canvas_y = self._pending_zoom_canvas_y
|
|
|
|
if self._current_zoom is None or self._current_map_geo_bounds is None or self._map_pil_image is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
|
|
logger.warning("Map context/libs missing during debounced zoom. Cannot zoom.")
|
|
return
|
|
|
|
map_pixel_shape = self._map_pil_image.size # Use (width, height)
|
|
geo_lon_at_mouse, geo_lat_at_mouse = _pixel_to_geo(canvas_x, canvas_y, self._current_map_geo_bounds, map_pixel_shape) # Pass (width, 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(self._current_zoom + zoom_direction, max_zoom_limit))
|
|
|
|
if new_zoom != self._current_zoom:
|
|
logger.info(f"Zoom changed from {self._current_zoom} to {new_zoom} (debounced).")
|
|
target_center_lat = geo_lat_at_mouse if geo_lat_at_mouse is not None else self._current_center_lat
|
|
target_center_lon = geo_lon_at_mouse if geo_lon_at_mouse is not None else self._current_center_lon
|
|
|
|
if target_center_lat is not None and target_center_lon is not None:
|
|
# When zooming, we want to maintain the current target point (under mouse) at the same screen pixel.
|
|
# Recenter based on the new zoom and the geo point at the mouse.
|
|
# The recenter_and_redraw logic should handle calculating the new center needed to achieve this.
|
|
# Alternatively, pass the desired new zoom and the point to center *on*.
|
|
# The current recenter_and_redraw expects the *final* center.
|
|
# Let's calculate the new center based on the zoom and mouse position.
|
|
# This is a bit more complex than just centering on geo_lat_at_mouse, geo_lon_at_mouse,
|
|
# as that would move the point under the mouse.
|
|
# A simpler approach is to just recenter on the calculated geo point under the mouse at the new zoom.
|
|
self.recenter_and_redraw(target_center_lat, target_center_lon, new_zoom, ensure_bbox_is_covered_dict=None) # No specific BBox to ensure coverage for during pan/zoom
|
|
else: logger.warning("Could not determine zoom center during debounced zoom.")
|
|
|
|
|
|
def _on_mouse_button_press(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists(): return
|
|
self._drag_start_x_canvas, self._drag_start_y_canvas = event.x, event.y
|
|
if self._current_center_lat is not None and self._current_center_lon is not None:
|
|
self._drag_start_center_lon, self._drag_start_center_lat = self._current_center_lon, self._current_center_lat
|
|
else: logger.warning("Mouse press but geo center unknown. Cannot pan."); self._drag_start_center_lon, self._drag_start_center_lat = None, None
|
|
|
|
self._is_dragging = False
|
|
try: self.canvas.config(cursor="fleur")
|
|
except tk.TclError: pass
|
|
|
|
|
|
def _on_mouse_drag(self, event: tk.Event):
|
|
if (self._drag_start_x_canvas is None or self._drag_start_y_canvas is None or self._drag_start_center_lon is None or self._drag_start_center_lat is None or self._current_map_geo_bounds is None or not self.canvas.winfo_exists() or self._current_zoom is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or self._map_pil_image is None):
|
|
if self.canvas.winfo_exists():
|
|
try: self.canvas.config(cursor="");
|
|
except tk.TclError: pass
|
|
return
|
|
|
|
dx_pixel, dy_pixel = event.x - self._drag_start_x_canvas, event.y - self._drag_start_y_canvas
|
|
drag_threshold = 5
|
|
if not self._is_dragging and (abs(dx_pixel) > drag_threshold or abs(dy_pixel) > drag_threshold):
|
|
self._is_dragging = True
|
|
|
|
# Optional: Provide visual feedback during drag by moving the canvas image
|
|
# if self._canvas_image_id is not None:
|
|
# self.canvas.coords(self._canvas_image_id, dx_pixel, dy_pixel)
|
|
|
|
|
|
def _on_mouse_button_release(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists():
|
|
try: self.canvas.config(cursor="");
|
|
except tk.TclError: pass; return
|
|
try: self.canvas.config(cursor="");
|
|
except tk.TclError: pass
|
|
|
|
if not self._is_dragging:
|
|
self._drag_start_x_canvas, self._drag_start_y_canvas, self._drag_start_center_lon, self._drag_start_center_lat = None, None, None, None
|
|
# MODIFIED: Handle left-click when not dragging.
|
|
# WHY: To allow users to click on the map to potentially select/identify objects.
|
|
# HOW: Added logic to call a controller method.
|
|
logger.debug(f"Map left-clicked at Canvas ({event.x}, {event.y}).")
|
|
# Convert click pixel to geo coordinate
|
|
if self._current_map_geo_bounds is not None and self._map_pil_image is not None and self._current_zoom is not None:
|
|
clicked_lon, clicked_lat = _pixel_to_geo(event.x, event.y, self._current_map_geo_bounds, self._map_pil_image.size) # Pass (width, height)
|
|
if clicked_lon is not None and clicked_lat is not None:
|
|
logger.debug(f"Left-click Geo: Lat={clicked_lat:.5f}, Lon={clicked_lon:.5f}")
|
|
# Delegate handling the click to the controller
|
|
if self.app_controller and hasattr(self.app_controller, "on_map_left_click"):
|
|
try: self.app_controller.on_map_left_click(clicked_lat, clicked_lon, event.x_root, event.y_root)
|
|
except Exception as e: logger.error(f"Error calling controller left click handler: {e}", exc_info=False)
|
|
else:
|
|
logger.warning(f"Failed to convert left click pixel ({event.x},{event.y}) to geo coordinate.")
|
|
else:
|
|
logger.warning("Map context missing for left click geo conversion.")
|
|
return
|
|
|
|
|
|
if (self._drag_start_x_canvas is None or self._drag_start_y_canvas is None or self._drag_start_center_lon is None or self._drag_start_center_lat is None or self._current_map_geo_bounds is None or self._current_zoom is None or not PIL_IMAGE_LIB_AVAILABLE or not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None or self._map_pil_image is None):
|
|
logger.warning("Cannot finalize pan: drag state/context/libs missing.")
|
|
self._is_dragging = False; self._drag_start_x_canvas = None; self._drag_start_y_canvas = None; self._drag_start_center_lon = None; self._drag_start_center_lat = None
|
|
# Reset image position if it was moved during drag for visual feedback
|
|
if self._canvas_image_id is not None and self.canvas.winfo_exists():
|
|
try: self.canvas.coords(self._canvas_image_id, 0, 0)
|
|
except tk.TclError: pass
|
|
return
|
|
|
|
|
|
dx_pixel, dy_pixel = event.x - self._drag_start_x_canvas, event.y - self._drag_start_y_canvas
|
|
|
|
# Reset image position if it was moved during drag for visual feedback
|
|
if self._canvas_image_id is not None and self.canvas.winfo_exists():
|
|
try: self.canvas.coords(self._canvas_image_id, 0, 0)
|
|
except tk.TclError: pass
|
|
|
|
|
|
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds
|
|
current_canvas_width = self.canvas_width
|
|
current_canvas_height = self.canvas_height
|
|
|
|
if current_canvas_width <= 0 or current_canvas_height <= 0: logger.warning("Canvas dims zero/invalid. Cannot calc pan delta."); self._is_dragging = False; return
|
|
|
|
# Calculate meters per pixel at the center latitude of the *current* map view.
|
|
# This is needed to convert pixel drag distance to meters.
|
|
center_lat_for_res = (map_south_lat + map_north_lat) / 2.0
|
|
center_lat_for_res = max(-85.05, min(85.05, center_lat_for_res)) # Clamp for Mercator
|
|
res_m_px_at_center = calculate_meters_per_pixel(center_lat_for_res, self._current_zoom, self.tile_manager.tile_size)
|
|
|
|
if res_m_px_at_center is None or res_m_px_at_center <= 1e-9:
|
|
logger.warning("Could not calculate meters per pixel for pan delta. Cannot pan reliably."); self._is_dragging = False; return
|
|
|
|
# Calculate the geographic distance corresponding to the pixel drag distance
|
|
delta_meters_x = dx_pixel * res_m_px_at_center
|
|
delta_meters_y = dy_pixel * res_m_px_at_center
|
|
|
|
|
|
# Use pyproj.Geod.fwd to calculate the new geographic center after moving by delta_meters from the start center.
|
|
# This is more accurate than simply multiplying degrees per pixel if the canvas covers a large area or is near poles.
|
|
geod = pyproj.Geod(ellps="WGS84")
|
|
|
|
# Calculate new center longitude and latitude separately.
|
|
# Moving east/west by delta_meters_x at start_center_lat.
|
|
# Moving north/south by delta_meters_y at start_center_lon.
|
|
# Note: Need to account for direction in fwd azimuth. East is 90 deg, West is 270 or -90. North is 0, South is 180.
|
|
# dx > 0 means mouse moved right (pan map left), so new center is left (delta_lon is negative). Azimuth is 270 if dx>0, 90 if dx<0.
|
|
# dy > 0 means mouse moved down (pan map up), so new center is up (delta_lat is positive). Azimuth is 0 if dy>0, 180 if dy<0.
|
|
|
|
# Calculate new longitude based on horizontal drag
|
|
if abs(delta_meters_x) > 1e-9:
|
|
azimuth_lon = 90.0 if delta_meters_x < 0 else 270.0 # If delta_x > 0, map moved right, center moves left (270). If delta_x < 0, map moved left, center moves right (90).
|
|
# Clamp start_center_lat for fwd calculation
|
|
start_center_lat_clamped = max(-89.99, min(89.99, self._drag_start_center_lat))
|
|
end_lon_from_drag_x, _, _ = geod.fwd(self._drag_start_center_lon, start_center_lat_clamped, azimuth_lon, abs(delta_meters_x))
|
|
new_center_lon = end_lon_from_drag_x
|
|
else:
|
|
new_center_lon = self._drag_start_center_lon # No horizontal movement
|
|
|
|
|
|
# Calculate new latitude based on vertical drag
|
|
if abs(delta_meters_y) > 1e-9:
|
|
azimuth_lat = 180.0 if delta_meters_y < 0 else 0.0 # If delta_y > 0, map moved down, center moves up (0). If delta_y < 0, map moved up, center moves down (180).
|
|
# Use the potentially updated longitude (new_center_lon) for vertical movement calculation,
|
|
# though this dependency is minor over small distances. Let's use the start_center_lon for simplicity.
|
|
start_center_lon_for_lat_drag = max(-179.99, min(179.99, self._drag_start_center_lon))
|
|
_, end_lat_from_drag_y, _ = geod.fwd(start_center_lon_for_lat_drag, self._drag_start_center_lat, azimuth_lat, abs(delta_meters_y))
|
|
new_center_lat = end_lat_from_drag_y
|
|
else:
|
|
new_center_lat = self._drag_start_center_lat # No vertical movement
|
|
|
|
|
|
# Ensure new center is within valid Mercator latitude range
|
|
MAX_MERCATOR_LATITUDE = 85.05112878
|
|
new_center_lat = max(-MAX_MERCATOR_LATITUDE, min(MAX_MERCATOR_LATITUDE, new_center_lat))
|
|
|
|
# Ensure new center longitude wraps correctly (-180 to 180)
|
|
new_center_lon = (new_center_lon + 180) % 360 - 180
|
|
|
|
|
|
logger.info(f"Pan finalized. New center: ({new_center_lat:.4f}, {new_center_lon:.4f})")
|
|
self.recenter_and_redraw(new_center_lat, new_center_lon, self._current_zoom, ensure_bbox_is_covered_dict=None)
|
|
|
|
self._is_dragging = False; self._drag_start_x_canvas = None; self._drag_start_y_canvas = None; self._drag_start_center_lon = None; self._drag_start_center_lat = None
|
|
|
|
|
|
def _on_right_click(self, event: tk.Event):
|
|
if not self.canvas.winfo_exists(): return
|
|
|
|
if self._current_map_geo_bounds is None or self._map_pil_image is None or self._current_zoom is None:
|
|
logger.warning("Map context missing for right click geo conversion.")
|
|
return
|
|
|
|
map_pixel_shape = self._map_pil_image.size
|
|
geo_lon_at_mouse, geo_lat_at_mouse = _pixel_to_geo(event.x, event.y, self._current_map_geo_bounds, map_pixel_shape)
|
|
|
|
if geo_lon_at_mouse is not None and geo_lat_at_mouse is not None:
|
|
logger.info(f"Right-click Geo: Lat={geo_lat_at_mouse:.5f}, Lon={geo_lon_at_mouse:.5f}")
|
|
if self.app_controller and hasattr(self.app_controller, "on_map_right_click"):
|
|
try: self.app_controller.on_map_right_click(geo_lat_at_mouse, geo_lon_at_mouse, event.x_root, event.y_root)
|
|
except Exception as e: logger.error(f"Error calling controller click handler: {e}", exc_info=False)
|
|
|
|
|
|
def update_flights_on_map(self, flight_states: List[CanonicalFlightState]):
|
|
logger.info(f"MapCanvasManager: Update flights received {len(flight_states)}.")
|
|
self._current_flights_to_display = flight_states
|
|
# MODIFIED: Trigger redraw after updating flights.
|
|
# WHY: To display the new flight data on the map.
|
|
# HOW: Added the call.
|
|
self._redraw_canvas_content()
|
|
|
|
|
|
def get_current_map_info(self) -> Dict[str, Any]:
|
|
map_size_km_w: Optional[float] = None; map_size_km_h: Optional[float] = None
|
|
|
|
if PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None and self._current_map_geo_bounds:
|
|
try:
|
|
size_km_tuple = calculate_geographic_bbox_size_km(self._current_map_geo_bounds)
|
|
if size_km_tuple: map_size_km_w, map_size_km_h = size_km_tuple
|
|
except Exception as e: logger.error(f"Error calc current map geo size: {e}", exc_info=False)
|
|
|
|
|
|
info = {
|
|
"center_lat": self._current_center_lat, "center_lon": self._current_center_lon,
|
|
"zoom": self._current_zoom,
|
|
"map_geo_bounds": self._current_map_geo_bounds, # MODIFIED: Include map geo bounds
|
|
"target_bbox_input": self._target_bbox_input, # MODIFIED: Include 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) # Include flight count
|
|
}
|
|
return info
|
|
|
|
|
|
def show_map_context_menu(
|
|
self, latitude: float, longitude: float, screen_x: int, screen_y: int
|
|
):
|
|
logger.info(f"Placeholder: Show context menu for click @ Geo ({latitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f}, {longitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f})")
|
|
if self.canvas.winfo_exists():
|
|
root_widget = self.canvas.winfo_toplevel()
|
|
try:
|
|
context_menu = tk.Menu(root_widget, tearoff=0)
|
|
context_menu.add_command(label=f"Info for {latitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f},{longitude:.{map_constants.COORDINATE_DECIMAL_PLACES}f}", state=tk.DISABLED)
|
|
context_menu.add_separator()
|
|
if self.app_controller and 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))
|
|
else: context_menu.add_command(label="Center map here (Controller missing)", state=tk.DISABLED)
|
|
# MODIFIED: Add "Set as Monitoring Area" option.
|
|
# WHY: Allow setting the right-clicked point as the center of a monitoring area.
|
|
# HOW: Added a menu command. Needs corresponding method in controller.
|
|
if self.app_controller and hasattr(self.app_controller, "set_bbox_around_coords"):
|
|
# Need to define a default size for the BBox when setting from a click.
|
|
# Let's use a small default area, e.g., 50x50 km.
|
|
default_area_km = 50.0
|
|
context_menu.add_command(label=f"Set {default_area_km:.0f}km Area Here", command=lambda: self.app_controller.set_bbox_around_coords(latitude, longitude, default_area_km))
|
|
else:
|
|
context_menu.add_command(label="Set Area Here (Controller missing)", state=tk.DISABLED)
|
|
|
|
|
|
context_menu.add_command(label="Get elevation here (TBD)")
|
|
try: context_menu.tk_popup(screen_x, screen_y);
|
|
finally: context_menu.grab_release()
|
|
except tk.TclError as e: logger.warning(f"TclError showing menu: {e}. GUI gone.")
|
|
except Exception as e: logger.error(f"Error creating/showing menu: {e}", exc_info=True)
|
|
|
|
|
|
def recenter_map_at_coords(self, lat: float, lon: float):
|
|
logger.info(f"Request to center map @ Geo ({lat:.{map_constants.COORDINATE_DECIMAL_PLACES}f}, {lon:.{map_constants.COORDINATE_DECIMAL_PLACES}f})")
|
|
if (self._current_center_lat is not None and self._current_center_lon is not None and 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 and PYPROJ_MODULE_LOCALLY_AVAILABLE and pyproj is not None):
|
|
self.recenter_and_redraw(lat, lon, self._current_zoom, ensure_bbox_is_covered_dict=None)
|
|
|
|
# MODIFIED: Added method to set a BBox centered around specific coordinates.
|
|
# WHY: To support the "Set as Monitoring Area" context menu option.
|
|
# HOW: Implemented a method that calculates the BBox and calls set_target_bbox.
|
|
def set_bbox_around_coords(self, center_lat: float, center_lon: float, area_size_km: float):
|
|
logger.info(f"Request to set BBox around Geo ({center_lat:.4f}, {center_lon:.4f}) with 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.")
|
|
return
|
|
|
|
try:
|
|
# Use the utility function to calculate the BBox
|
|
bbox_tuple = map_utils.get_bounding_box_from_center_size(center_lat, center_lon, area_size_km)
|
|
if bbox_tuple:
|
|
bbox_dict = {
|
|
"lon_min": bbox_tuple[0],
|
|
"lat_min": bbox_tuple[1],
|
|
"lon_max": bbox_tuple[2],
|
|
"lat_max": bbox_tuple[3],
|
|
}
|
|
if _is_valid_bbox_dict(bbox_dict):
|
|
logger.debug(f"Calculated BBox around coords: {bbox_dict}. Setting target BBox.")
|
|
# Call set_target_bbox to update the view and draw the blue box
|
|
self.set_target_bbox(bbox_dict)
|
|
# Also update the GUI input fields with the new BBox values
|
|
if self.app_controller and hasattr(self.app_controller, "update_bbox_gui_fields"):
|
|
try:
|
|
self.app_controller.update_bbox_gui_fields(bbox_dict)
|
|
except Exception as e_update_gui:
|
|
logger.error(f"Error updating BBox GUI fields: {e_update_gui}", exc_info=False)
|
|
|
|
else:
|
|
logger.error(f"Calculated BBox around coords is invalid: {bbox_dict}.")
|
|
if self.app_controller and hasattr(self.app_controller, "show_error_message"):
|
|
self.app_controller.show_error_message("Map Error", "Calculated BBox is invalid.")
|
|
|
|
else:
|
|
logger.error(f"Failed to calculate BBox around coords ({center_lat}, {center_lon}, {area_size_km}km).")
|
|
if self.app_controller and hasattr(self.app_controller, "show_error_message"):
|
|
self.app_controller.show_error_message("Map Error", "Failed to calculate BBox around coordinates.")
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Unexpected error calculating BBox around coords: {e}")
|
|
if self.app_controller and hasattr(self.app_controller, "show_error_message"):
|
|
self.app_controller.show_error_message("Map Error", f"An unexpected error occurred calculating BBox: {e}") |