SXXXXXXX_FlightMonitor/flightmonitor/gui/panels/map_info_panel.py
2025-06-04 10:14:38 +02:00

355 lines
16 KiB
Python

# FlightMonitor/gui/panels/map_info_panel.py
"""
Panel for displaying various map-related information:
clicked coordinates, map bounds, target bounding box, zoom level,
flight count, and map size.
"""
import tkinter as tk
from tkinter import ttk
from typing import Dict, Any, Optional, Tuple
from ...utils.logger import get_logger
from ...data import config as app_config
from ...map.map_utils import _is_valid_bbox_dict # Used for BBox validation internally
from ...utils.gui_utils import GUI_STATUS_UNKNOWN # Used for default color in status updates
module_logger = get_logger(__name__)
# Colors for BBox status display, matched with MainWindow's previous definitions
BBOX_COLOR_INSIDE = "green4"
BBOX_COLOR_OUTSIDE = "red2"
BBOX_COLOR_PARTIAL = "darkorange"
BBOX_COLOR_NA = "gray50"
class MapInfoPanel:
"""
Manages the GUI elements for displaying map-related information.
"""
def __init__(self, parent_frame: ttk.Frame):
"""
Initializes the MapInfoPanel.
Args:
parent_frame: The parent ttk.Frame where this panel will be placed.
"""
self.parent_frame = parent_frame
# Configura le colonne per un layout a griglia pulito, con le etichette dei valori più piccole.
self.parent_frame.columnconfigure(1, weight=0)
self.parent_frame.columnconfigure(3, weight=0)
# --- Clicked Map Coordinates ---
info_row = 0
ttk.Label(self.parent_frame, text="Click Lat:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_lat_value = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_lat_value.grid(
row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5)
)
ttk.Label(self.parent_frame, text="Lon:").grid(
row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2)
)
self.info_lon_value = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_lon_value.grid(
row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5)
)
info_row += 1
ttk.Label(self.parent_frame, text="Lat DMS:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_lat_dms_value = ttk.Label(
self.parent_frame, text="N/A", width=18, wraplength=140
)
self.info_lat_dms_value.grid(
row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5)
)
ttk.Label(self.parent_frame, text="Lon DMS:").grid(
row=info_row, column=2, padx=(5, 2), pady=2, sticky=tk.W
)
self.info_lon_dms_value = ttk.Label(
self.parent_frame, text="N/A", width=18, wraplength=140
)
self.info_lon_dms_value.grid(
row=info_row, column=3, padx=(0, 0), pady=2, sticky=tk.EW
)
info_row += 1
ttk.Separator(self.parent_frame, orient=tk.HORIZONTAL).grid(
row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=2
)
# --- Current Map Geographic Bounds ---
info_row += 1
ttk.Label(self.parent_frame, text="Map N:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_map_bounds_n = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_map_bounds_n.grid(
row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5)
)
ttk.Label(self.parent_frame, text="W:").grid(
row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2)
)
self.info_map_bounds_w = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_map_bounds_w.grid(
row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5)
)
info_row += 1
ttk.Label(self.parent_frame, text="Map S:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_map_bounds_s = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_map_bounds_s.grid(
row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5)
)
ttk.Label(self.parent_frame, text="E:").grid(
row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2)
)
self.info_map_bounds_e = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_map_bounds_e.grid(
row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5)
)
info_row += 1
ttk.Separator(self.parent_frame, orient=tk.HORIZONTAL).grid(
row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=2
)
# --- Target Bounding Box Input (Monitoring Area) ---
info_row += 1
ttk.Label(self.parent_frame, text="Target N:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_target_bbox_n = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_target_bbox_n.grid(
row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5)
)
ttk.Label(self.parent_frame, text="W:").grid(
row=info_row, column=2, padx=(5, 2), pady=2, sticky=tk.W
)
self.info_target_bbox_w = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_target_bbox_w.grid(
row=info_row, column=3, padx=(0, 5), pady=1, sticky=tk.W
)
info_row += 1
ttk.Label(self.parent_frame, text="Target S:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_target_bbox_s = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_target_bbox_s.grid(
row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5)
)
ttk.Label(self.parent_frame, text="E:").grid(
row=info_row, column=2, padx=(5, 2), pady=2, sticky=tk.W
)
self.info_target_bbox_e = ttk.Label(self.parent_frame, text="N/A", width=12)
self.info_target_bbox_e.grid(
row=info_row, column=3, padx=(0, 5), pady=1, sticky=tk.W
)
info_row += 1
ttk.Separator(self.parent_frame, orient=tk.HORIZONTAL).grid(
row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=2
)
# --- General Map Metrics (Zoom, Flights, Size) ---
info_row += 1
ttk.Label(self.parent_frame, text="Zoom:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_zoom_value = ttk.Label(self.parent_frame, text="N/A", width=4)
self.info_zoom_value.grid(
row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5)
)
ttk.Label(self.parent_frame, text="Flights:").grid(
row=info_row, column=2, padx=(5, 2), pady=2, sticky=tk.W
)
self.info_flight_count_value = ttk.Label(self.parent_frame, text="N/A", width=5)
self.info_flight_count_value.grid(
row=info_row, column=3, padx=(0, 5), pady=1, sticky=tk.W
)
info_row += 1
ttk.Label(self.parent_frame, text="Map Size:").grid(
row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2)
)
self.info_map_size_value = ttk.Label(self.parent_frame, text="N/A", wraplength=180)
self.info_map_size_value.grid(
row=info_row, column=1, columnspan=3, sticky=tk.W, pady=1, padx=(0, 5)
)
module_logger.debug("MapInfoPanel initialized.")
def update_clicked_map_info(
self,
lat_deg: Optional[float],
lon_deg: Optional[float],
lat_dms: str,
lon_dms: str,
):
"""
Updates the display for the geographic coordinates of a clicked point on the map.
Args:
lat_deg: Latitude in decimal degrees (Optional).
lon_deg: Longitude in decimal degrees (Optional).
lat_dms: Latitude formatted as Degrees-Minutes-Seconds string.
lon_dms: Longitude formatted as Degrees-Minutes-Seconds string.
"""
if not (hasattr(self, "info_lat_value") and self.info_lat_value.winfo_exists()):
return # Panel not fully initialized or destroyed
decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5)
try:
# Update decimal degree labels
self.info_lat_value.config(
text=f"{lat_deg:.{decimals}f}" if lat_deg is not None else "N/A"
)
self.info_lon_value.config(
text=f"{lon_deg:.{decimals}f}" if lon_deg is not None else "N/A"
)
# Update DMS labels (already formatted strings)
self.info_lat_dms_value.config(text=lat_dms or "N/A")
self.info_lon_dms_value.config(text=lon_dms or "N/A")
except tk.TclError:
# Widgets might be destroyed during application shutdown
module_logger.debug("TclError updating clicked map info, widgets likely destroyed.")
except Exception as e_update_click:
module_logger.error(f"Error updating clicked map info panel: {e_update_click}", exc_info=False)
def update_general_map_info_display(
self,
zoom: Optional[int],
map_size_str: str, # Already formatted (e.g., "100km x 80km")
map_geo_bounds: Optional[Tuple[float, float, float, float]], # (W, S, E, N)
target_bbox_input: Optional[Dict[str, float]], # Standard dict {lat_min, lon_min, lat_max, lon_max}
flight_count: Optional[int],
):
"""
Updates the display for general map information like current map bounds,
target monitoring area, zoom level, total flights shown, and map size.
Args:
zoom: Current map zoom level.
map_size_str: Formatted string representing the map's current geographic size (e.g., "500km x 300km").
map_geo_bounds: Tuple (west_lon, south_lat, east_lon, north_lat) representing the currently displayed map's bounds.
target_bbox_input: Dictionary with keys "lat_min", "lon_min", "lat_max", "lon_max"
representing the user-defined monitoring area.
flight_count: Number of flights currently displayed on the map.
"""
if not (hasattr(self, "info_zoom_value") and self.info_zoom_value.winfo_exists()):
return # Panel not fully initialized or destroyed
decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5)
try:
# --- Map Geographic Bounds ---
map_w, map_s, map_e, map_n = ("N/A",) * 4
if map_geo_bounds:
map_w_val, map_s_val, map_e_val, map_n_val = map_geo_bounds
map_w, map_s, map_e, map_n = (
f"{map_w_val:.{decimals}f}",
f"{map_s_val:.{decimals}f}",
f"{map_e_val:.{decimals}f}",
f"{map_n_val:.{decimals}f}",
)
self.info_map_bounds_w.config(text=map_w)
self.info_map_bounds_s.config(text=map_s)
self.info_map_bounds_e.config(text=map_e)
self.info_map_bounds_n.config(text=map_n)
# --- Target BBox Input (Monitoring Area) ---
target_w, target_s, target_e, target_n = ("N/A",) * 4
color_for_target_bbox = BBOX_COLOR_NA # Default color
if target_bbox_input and _is_valid_bbox_dict(target_bbox_input):
# Ensure correct mapping from dictionary keys to geographic coordinates for display
target_n = f"{target_bbox_input['lat_max']:.{decimals}f}" # North: max_lat
target_w = f"{target_bbox_input['lon_min']:.{decimals}f}" # West: min_lon
target_s = f"{target_bbox_input['lat_min']:.{decimals}f}" # South: min_lat
target_e = f"{target_bbox_input['lon_max']:.{decimals}f}" # East: max_lon
# Determine color based on how target BBox relates to current map view
if map_geo_bounds:
relation_status = self._is_bbox_inside_bbox(
target_bbox_input, map_geo_bounds
)
if relation_status == "Inside":
color_for_target_bbox = BBOX_COLOR_INSIDE
elif relation_status == "Partial":
color_for_target_bbox = BBOX_COLOR_PARTIAL
else:
color_for_target_bbox = BBOX_COLOR_OUTSIDE
else: # No map view bounds, can't determine relation
color_for_target_bbox = BBOX_COLOR_NA
self.info_target_bbox_n.config(text=target_n, foreground=color_for_target_bbox)
self.info_target_bbox_w.config(text=target_w, foreground=color_for_target_bbox)
self.info_target_bbox_s.config(text=target_s, foreground=color_for_target_bbox)
self.info_target_bbox_e.config(text=target_e, foreground=color_for_target_bbox)
# --- Other General Info ---
self.info_zoom_value.config(text=str(zoom) if zoom is not None else "N/A")
self.info_map_size_value.config(text=map_size_str or "N/A")
self.info_flight_count_value.config(text=str(flight_count) if flight_count is not None else "N/A")
except tk.TclError:
module_logger.debug("TclError updating general map info, widgets likely destroyed.")
except Exception as e_update_gen_info:
module_logger.error(f"Error updating general map info panel: {e_update_gen_info}", exc_info=False)
def _is_bbox_inside_bbox(
self,
inner_bbox_dict: Dict[str, float], # Standard dict {lat_min, lon_min, ...}
outer_bbox_tuple: Tuple[float, float, float, float], # (W, S, E, N)
) -> str:
"""
Determines if an inner bounding box is fully inside, partially overlaps,
or is entirely outside of an outer bounding box.
Args:
inner_bbox_dict: The inner bounding box as a dictionary.
outer_bbox_tuple: The outer bounding box as a tuple (west, south, east, north).
Returns:
"Inside", "Outside", "Partial", or "N/A" if inputs are invalid.
"""
# Validate inputs using _is_valid_bbox_dict and basic tuple checks
if not _is_valid_bbox_dict(inner_bbox_dict) or not (
outer_bbox_tuple
and len(outer_bbox_tuple) == 4
and all(isinstance(c, (int, float)) for c in outer_bbox_tuple)
):
return "N/A"
# Convert outer tuple to a comparable dict for clarity and consistency
outer_dict = {
"lon_min": outer_bbox_tuple[0],
"lat_min": outer_bbox_tuple[1],
"lon_max": outer_bbox_tuple[2],
"lat_max": outer_bbox_tuple[3],
}
eps = 1e-6 # Epsilon for robust float comparisons
# Check for full containment
# If inner's min is >= outer's min, AND inner's max is <= outer's max for both lat/lon
if (
inner_bbox_dict["lon_min"] >= outer_dict["lon_min"] - eps
and inner_bbox_dict["lat_min"] >= outer_dict["lat_min"] - eps
and inner_bbox_dict["lon_max"] <= outer_dict["lon_max"] + eps
and inner_bbox_dict["lat_max"] <= outer_dict["lat_max"] + eps
):
return "Inside"
# Check for no overlap (completely outside)
# If inner is entirely to the right OR entirely to the left OR entirely above OR entirely below the outer
if (
inner_bbox_dict["lon_min"] >= outer_dict["lon_max"] - eps # Inner right of outer
or inner_bbox_dict["lon_max"] <= outer_dict["lon_min"] + eps # Inner left of outer
or inner_bbox_dict["lat_min"] >= outer_dict["lat_max"] - eps # Inner above outer
or inner_bbox_dict["lat_max"] <= outer_dict["lat_min"] + eps # Inner below outer
):
return "Outside"
# If it's not fully inside and not entirely outside, it must be partial
return "Partial"