added meridians and parallels to the map

This commit is contained in:
VALLONGOL 2025-06-13 10:08:23 +02:00
parent e74e27bb4e
commit 87cc1850c3
4 changed files with 198 additions and 224 deletions

View File

@ -8,6 +8,7 @@ from collections import deque
import queue import queue
import threading import threading
import copy import copy
import os # Aggiunto per _load_label_font
try: try:
from PIL import Image, ImageTk, ImageDraw, ImageFont from PIL import Image, ImageTk, ImageDraw, ImageFont
@ -120,6 +121,9 @@ class MapCanvasManager:
return None, None, "Failed to stitch map" return None, None, "Failed to stitch map"
stitched_bounds = self.tile_manager._get_bounds_for_tile_range(zoom, tile_ranges) stitched_bounds = self.tile_manager._get_bounds_for_tile_range(zoom, tile_ranges)
if not stitched_bounds: # Aggiunto controllo di robustezza
return None, None, "Failed to get bounds for stitched map"
center_px = geo_to_pixel_on_unscaled_map(center_lat, center_lon, stitched_bounds, stitched_map.size) center_px = geo_to_pixel_on_unscaled_map(center_lat, center_lon, stitched_bounds, stitched_map.size)
if not center_px: if not center_px:
center_px = (stitched_map.width / 2, stitched_map.height / 2) center_px = (stitched_map.width / 2, stitched_map.height / 2)
@ -134,24 +138,39 @@ class MapCanvasManager:
if not final_bounds: if not final_bounds:
return None, None, "Failed to calculate final map bounds" return None, None, "Failed to calculate final map bounds"
image_to_draw = final_image.convert("RGBA") # --- MODIFICA CHIAVE: Logica di disegno con overlay trasparente ---
draw = ImageDraw.Draw(image_to_draw) # 1. Crea un overlay trasparente delle stesse dimensioni della mappa
overlay_rgba = Image.new("RGBA", final_image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay_rgba)
# 2. Esegui tutti i disegni sull'overlay trasparente
if map_constants.GRATICULE_ENABLED and final_bounds:
graticule_font = map_drawing._load_label_font(0, is_graticule=True)
map_drawing.draw_graticules(draw, final_bounds, overlay_rgba.size, graticule_font)
if draw_target_bbox and target_bbox and map_utils._is_valid_bbox_dict(target_bbox): if draw_target_bbox and target_bbox and map_utils._is_valid_bbox_dict(target_bbox):
bbox_wesn = (target_bbox["lon_min"], target_bbox["lat_min"], target_bbox["lon_max"], target_bbox["lat_max"]) bbox_wesn = (target_bbox["lon_min"], target_bbox["lat_min"], target_bbox["lon_max"], target_bbox["lat_max"])
map_drawing.draw_area_bounding_box(image_to_draw, bbox_wesn, final_bounds, image_to_draw.size, "blue", 2) map_drawing.draw_area_bounding_box(overlay_rgba, bbox_wesn, final_bounds, overlay_rgba.size, "blue", 2)
if flights: if flights:
font = map_drawing._load_label_font(12 + (zoom - 10)) label_font_size = 10 + (zoom - 10)
flight_label_font = map_drawing._load_label_font(label_font_size)
for flight in flights: for flight in flights:
if flight.latitude is not None and flight.longitude is not None: if flight.latitude is not None and flight.longitude is not None:
pixel_coords = map_drawing.geo_to_pixel_on_unscaled_map(flight.latitude, flight.longitude, final_bounds, image_to_draw.size) pixel_coords = map_drawing.geo_to_pixel_on_unscaled_map(flight.latitude, flight.longitude, final_bounds, overlay_rgba.size)
if pixel_coords: if pixel_coords:
map_drawing._draw_single_flight( map_drawing._draw_single_flight(
draw, pixel_coords, flight, font, tracks.get(flight.icao24), final_bounds, image_to_draw.size draw, pixel_coords, flight, flight_label_font, tracks.get(flight.icao24), final_bounds, overlay_rgba.size
) )
return ImageTk.PhotoImage(image_to_draw), final_bounds, None # 3. Fai il 'composite' dell'overlay sulla mappa di base
# Prima converto l'immagine base in RGBA per poter fare il composite
base_map_rgba = final_image.convert("RGBA")
composite_image = Image.alpha_composite(base_map_rgba, overlay_rgba)
# 4. Crea il PhotoImage dall'immagine composita finale
return ImageTk.PhotoImage(composite_image), final_bounds, None
# --- FINE MODIFICA ---
def _start_gui_result_processing(self): def _start_gui_result_processing(self):
if self._gui_after_id_result_processor and self.canvas.winfo_exists(): if self._gui_after_id_result_processor and self.canvas.winfo_exists():
@ -214,6 +233,9 @@ class MapCanvasManager:
self.canvas.bind("<B1-Motion>", self._on_mouse_drag) self.canvas.bind("<B1-Motion>", self._on_mouse_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_left_button_release) self.canvas.bind("<ButtonRelease-1>", self._on_left_button_release)
self.canvas.bind("<ButtonPress-3>", self._on_right_click) self.canvas.bind("<ButtonPress-3>", self._on_right_click)
self.canvas.bind("<MouseWheel>", self._on_mouse_wheel)
self.canvas.bind("<Button-4>", self._on_mouse_wheel_linux)
self.canvas.bind("<Button-5>", self._on_mouse_wheel_linux)
self._drag_start_x_canvas, self._drag_start_y_canvas = None, None self._drag_start_x_canvas, self._drag_start_y_canvas = None, None
def _on_canvas_resize(self, event: tk.Event): def _on_canvas_resize(self, event: tk.Event):
@ -260,9 +282,18 @@ class MapCanvasManager:
self.app_controller.on_map_left_click(lat, lon, event.x, event.y, event.x_root, event.y_root) self.app_controller.on_map_left_click(lat, lon, event.x, event.y, event.x_root, event.y_root)
self._drag_start_x_canvas = None self._drag_start_x_canvas = None
def _on_mouse_wheel(self, event: tk.Event):
if self._current_zoom_gui is None: return
if event.delta > 0: self.zoom_in_at_center()
else: self.zoom_out_at_center()
def _on_mouse_wheel_linux(self, event: tk.Event):
if self._current_zoom_gui is None: return
if event.num == 4: self.zoom_in_at_center()
elif event.num == 5: self.zoom_out_at_center()
def get_clicked_flight_icao(self, canvas_x: int, canvas_y: int) -> Optional[str]: def get_clicked_flight_icao(self, canvas_x: int, canvas_y: int) -> Optional[str]:
if not self._current_map_geo_bounds_gui or not self._map_photo_image: if not self._current_map_geo_bounds_gui or not self._map_photo_image: return None
return None
min_dist_sq, clicked_icao = float('inf'), None min_dist_sq, clicked_icao = float('inf'), None
radius_sq = 15**2 radius_sq = 15**2
map_size = (self._map_photo_image.width(), self._map_photo_image.height()) map_size = (self._map_photo_image.width(), self._map_photo_image.height())
@ -313,7 +344,7 @@ class MapCanvasManager:
if not preserve_zoom or not zoom: if not preserve_zoom or not zoom:
size_km = map_utils.calculate_geographic_bbox_size_km((bbox["lon_min"], bbox["lat_min"], bbox["lon_max"], bbox["lat_max"])) size_km = map_utils.calculate_geographic_bbox_size_km((bbox["lon_min"], bbox["lat_min"], bbox["lon_max"], bbox["lat_max"]))
if size_km: if size_km:
w, h = self.canvas_width * 0.9, self.canvas_height * 0.9 w, h = self.canvas_width * (1 - CANVAS_MARGIN_FACTOR_FOR_BBOX_FIT), self.canvas_height * (1 - CANVAS_MARGIN_FACTOR_FOR_BBOX_FIT)
zoom_w = map_utils.calculate_zoom_level_for_geographic_size(center_lat, size_km[0]*1000, int(w), self.tile_manager.tile_size) zoom_w = map_utils.calculate_zoom_level_for_geographic_size(center_lat, size_km[0]*1000, int(w), self.tile_manager.tile_size)
zoom_h = map_utils.calculate_zoom_level_for_geographic_size(center_lat, size_km[1]*1000, int(h), self.tile_manager.tile_size) zoom_h = map_utils.calculate_zoom_level_for_geographic_size(center_lat, size_km[1]*1000, int(h), self.tile_manager.tile_size)
zoom = min(zoom_w, zoom_h) if zoom_w and zoom_h else map_constants.DEFAULT_INITIAL_ZOOM zoom = min(zoom_w, zoom_h) if zoom_w and zoom_h else map_constants.DEFAULT_INITIAL_ZOOM
@ -321,15 +352,12 @@ class MapCanvasManager:
def _clear_canvas_display_elements(self): def _clear_canvas_display_elements(self):
if self.canvas.winfo_exists(): if self.canvas.winfo_exists():
if self._canvas_image_id: if self._canvas_image_id: self.canvas.delete(self._canvas_image_id)
self.canvas.delete(self._canvas_image_id) if self._placeholder_text_id: self.canvas.delete(self._placeholder_text_id)
if self._placeholder_text_id:
self.canvas.delete(self._placeholder_text_id)
self._canvas_image_id, self._placeholder_text_id, self._map_photo_image = None, None, None self._canvas_image_id, self._placeholder_text_id, self._map_photo_image = None, None, None
def _display_placeholder_text(self, text: str): def _display_placeholder_text(self, text: str):
if not self.canvas.winfo_exists(): if not self.canvas.winfo_exists(): return
return
self._clear_canvas_display_elements() self._clear_canvas_display_elements()
self.canvas.configure(bg=getattr(map_constants, "DEFAULT_PLACEHOLDER_COLOR_RGB_TK", "gray85")) self.canvas.configure(bg=getattr(map_constants, "DEFAULT_PLACEHOLDER_COLOR_RGB_TK", "gray85"))
w, h = self.canvas.winfo_width() or self.canvas_width, self.canvas.winfo_height() or self.canvas_height w, h = self.canvas.winfo_width() or self.canvas_width, self.canvas.winfo_height() or self.canvas_height
@ -372,8 +400,7 @@ class MapCanvasManager:
self.map_render_manager.shutdown_worker() self.map_render_manager.shutdown_worker()
def recenter_map_at_coords(self, lat: float, lon: float): def recenter_map_at_coords(self, lat: float, lon: float):
if self._current_zoom_gui is not None: if self._current_zoom_gui is not None: self._request_map_render(lat, lon, self._current_zoom_gui)
self._request_map_render(lat, lon, self._current_zoom_gui)
def set_bbox_around_coords(self, lat: float, lon: float, size_km: float): def set_bbox_around_coords(self, lat: float, lon: float, size_km: float):
bbox_tuple = map_utils.get_bounding_box_from_center_size(lat, lon, size_km) bbox_tuple = map_utils.get_bounding_box_from_center_size(lat, lon, size_km)
@ -389,11 +416,9 @@ class MapCanvasManager:
self._request_map_render(self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui - 1) self._request_map_render(self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui - 1)
def pan_map_fixed_step(self, direction: str): def pan_map_fixed_step(self, direction: str):
if not all([self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, PYPROJ_MODULE_LOCALLY_AVAILABLE, map_utils.pyproj]): if not all([self._current_center_lat_gui, self._current_center_lon_gui, self._current_zoom_gui, PYPROJ_MODULE_LOCALLY_AVAILABLE, map_utils.pyproj]): return
return
res = map_utils.calculate_meters_per_pixel(self._current_center_lat_gui, self._current_zoom_gui, self.tile_manager.tile_size) res = map_utils.calculate_meters_per_pixel(self._current_center_lat_gui, self._current_zoom_gui, self.tile_manager.tile_size)
if not res: if not res: return
return
dx, dy = 0, 0 dx, dy = 0, 0
if direction == "left": dx = -self.canvas_width * PAN_STEP_FRACTION if direction == "left": dx = -self.canvas_width * PAN_STEP_FRACTION
elif direction == "right": dx = self.canvas_width * PAN_STEP_FRACTION elif direction == "right": dx = self.canvas_width * PAN_STEP_FRACTION

View File

@ -7,139 +7,52 @@ from typing import Optional, Tuple
# --- Map Drawing Colors and Styles --- # --- Map Drawing Colors and Styles ---
# Colors for drawing boundaries on the map.
DEM_BOUNDARY_COLOR: str = "red"
DEM_BOUNDARY_THICKNESS_PX: int = 3
AREA_BOUNDARY_COLOR: str = "blue" AREA_BOUNDARY_COLOR: str = "blue"
AREA_BOUNDARY_THICKNESS_PX: int = 2 AREA_BOUNDARY_THICKNESS_PX: int = 2
# Colors for text labels drawn on tiles (e.g., DEM tile names).
TILE_TEXT_COLOR: str = "white" TILE_TEXT_COLOR: str = "white"
# Use RGBA for transparency in background if supported by PIL/ImageDraw TILE_TEXT_BG_COLOR: str = "rgba(0, 0, 0, 150)"
TILE_TEXT_BG_COLOR: str = "rgba(0, 0, 0, 150)" # Semi-transparent black background
# --- Graticule (Grid) Constants ---
GRATICULE_ENABLED: bool = True
GRATICULE_LINE_COLOR: str = "rgba(100, 100, 100, 128)"
GRATICULE_LINE_WIDTH: int = 1
GRATICULE_LABEL_FONT_SIZE: int = 10
GRATICULE_MIN_PIXEL_SPACING: int = 200
# --- Map Labeling Constants --- # --- Map Labeling Constants ---
# Base font size for map tile labels and the corresponding zoom level.
# Font size will be scaled based on the current map zoom relative to this base zoom.
DEM_TILE_LABEL_BASE_FONT_SIZE: int = 12
DEM_TILE_LABEL_BASE_ZOOM: int = 10
# Optional: Default font path for drawing text.
# If None, ImageFont.load_default() will be used as the primary font source.
# If a path is provided, MapCanvasManager/map_drawing might attempt to load
# a TrueType font from this path, falling back to default if it fails.
# Example: DEFAULT_LABEL_FONT_PATH = "/path/to/your/font.ttf"
DEFAULT_LABEL_FONT_PATH: Optional[str] = None DEFAULT_LABEL_FONT_PATH: Optional[str] = None
# --- Map Behavior Constants --- # --- Map Behavior Constants ---
# Default zoom level when the map is initialized or reset if no BBox is set.
DEFAULT_INITIAL_ZOOM: int = 7 DEFAULT_INITIAL_ZOOM: int = 7
# Minimum and maximum allowed zoom levels for interactive pan/zoom.
MIN_ZOOM_LEVEL: int = 0 MIN_ZOOM_LEVEL: int = 0
# Note: Max zoom is often determined by the map tile service,
# this provides a fallback/hard limit if service info isn't available.
DEFAULT_MAX_ZOOM_FALLBACK: int = 19 DEFAULT_MAX_ZOOM_FALLBACK: int = 19
# --- Placeholders and Fallbacks --- # --- Placeholders and Fallbacks ---
DEFAULT_PLACEHOLDER_COLOR_RGB: Tuple[int, int, int] = (220, 220, 220)
# Default color for placeholder tile images when a tile cannot be loaded. DEFAULT_PLACEHOLDER_COLOR_RGB_TK: str = "gray85"
DEFAULT_PLACEHOLDER_COLOR_RGB: Tuple[int, int, int] = (220, 220, 220) # Light grey
# MODIFIED: Added constant for Tkinter canvas background color for placeholders.
# WHY: Needed by MapCanvasManager to set the canvas background when displaying placeholder text.
# HOW: Added the new constant definition.
DEFAULT_PLACEHOLDER_COLOR_RGB_TK: str = (
"gray85" # Tkinter color string for canvas background
)
# --- Map Information Panel Formatting --- # --- Map Information Panel Formatting ---
# Number of decimal places to display for coordinates in the info panel.
COORDINATE_DECIMAL_PLACES: int = 5 COORDINATE_DECIMAL_PLACES: int = 5
MAP_SIZE_KM_DECIMAL_PLACES: int = 1 # Number of decimal places for map size in km. MAP_SIZE_KM_DECIMAL_PLACES: int = 1
# --- Simboli DMS ---
# Define standard degree, minute, second symbols for DMS formatting
DMS_DEGREE_SYMBOL: str = "°" DMS_DEGREE_SYMBOL: str = "°"
DMS_MINUTE_SYMBOL: str = "'" DMS_MINUTE_SYMBOL: str = "'"
DMS_SECOND_SYMBOL: str = "''" DMS_SECOND_SYMBOL: str = "''"
# MODIFIED: Paletta colori # Palette colori per le tracce
# WHY: Paletta ampliata
# HOW: Ampliata la lista dei colori
TRACK_COLOR_PALETTE = [ TRACK_COLOR_PALETTE = [
"#FF0000", "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF",
"#00FF00", "#800000", "#008000", "#000080", "#808000", "#800080", "#008080",
"#0000FF", "#C0C0C0", "#400000", "#004000", "#000040", "#404000", "#400040",
"#FFFF00", "#004040", "#202020", "#FF8000", "#80FF00", "#00FF80", "#8000FF",
"#FF00FF", "#FF0080", "#0080FF", "#808080", "#A0522D", "#D2691E", "#DAA520",
"#00FFFF", "#BDB76B", "#8FBC8F", "#FA8072", "#E9967A", "#F08080", "#CD5C5C",
"#800000", "#DC143C", "#B22222", "#FF69B4", "#FF1493", "#C71585", "#DB7093",
"#008000", "#E6E6FA", "#D8BFD8", "#DDA0DD", "#EE82EE", "#9932CC", "#8A2BE2",
"#000080", "#BA55D3", "#9370DB", "#7B68EE", "#6A5ACD", "#483D8B", "#708090",
"#808000", "#778899", "#B0C4DE", "#ADD8E6", "#87CEEB", "#87CEFA", "#00BFFF",
"#800080", "#1E90FF", "#6495ED", "#4682B4", "#0000CD", "#00008B",
"#008080",
"#C0C0C0",
"#400000",
"#004000",
"#000040",
"#404000",
"#400040",
"#004040",
"#202020",
"#FF8000",
"#80FF00",
"#00FF80",
"#8000FF",
"#FF0080",
"#0080FF",
"#808080",
"#A0522D",
"#D2691E",
"#DAA520",
"#BDB76B",
"#8FBC8F",
"#FA8072",
"#E9967A",
"#F08080",
"#CD5C5C",
"#DC143C",
"#B22222",
"#FF69B4",
"#FF1493",
"#C71585",
"#DB7093",
"#E6E6FA",
"#D8BFD8",
"#DDA0DD",
"#EE82EE",
"#9932CC",
"#8A2BE2",
"#BA55D3",
"#9370DB",
"#7B68EE",
"#6A5ACD",
"#483D8B",
"#708090",
"#778899",
"#B0C4DE",
"#ADD8E6",
"#87CEEB",
"#87CEFA",
"#00BFFF",
"#1E90FF",
"#6495ED",
"#4682B4",
"#0000CD",
"#00008B",
] ]
"""
Palette di 64 colori da utilizzare per le tracce degli aerei
"""

View File

@ -4,6 +4,7 @@ import logging
import math import math
from typing import Optional, Tuple, List, Dict, Any from typing import Optional, Tuple, List, Dict, Any
from collections import deque from collections import deque
import os
try: try:
from PIL import Image, ImageDraw, ImageFont, ImageColor from PIL import Image, ImageDraw, ImageFont, ImageColor
@ -15,29 +16,26 @@ except ImportError:
logging.error("MapDrawing: Pillow not found. Drawing disabled.") logging.error("MapDrawing: Pillow not found. Drawing disabled.")
try: try:
from .map_utils import geo_to_pixel_on_unscaled_map from .map_utils import geo_to_pixel_on_unscaled_map, calculate_graticule_interval
from ..data.common_models import CanonicalFlightState from ..data.common_models import CanonicalFlightState
from . import map_constants from . import map_constants
from ..data import config as app_config from ..data import config as app_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TRACK_LINE_WIDTH = getattr(app_config, "DEFAULT_TRACK_LINE_WIDTH", 2) TRACK_LINE_WIDTH = 2
except ImportError as e: except ImportError as e:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f"MapDrawing failed to import dependencies: {e}. Using fallbacks.") logger.error(f"MapDrawing failed to import dependencies: {e}. Using fallbacks.")
# Fallback classes and constants if imports fail
class CanonicalFlightState: pass class CanonicalFlightState: pass
class MockMapConstants: class MockMapConstants:
AREA_BOUNDARY_COLOR = "blue" AREA_BOUNDARY_COLOR, TILE_TEXT_BG_COLOR, TILE_TEXT_COLOR = "blue", "rgba(0, 0, 0, 150)", "white"
AREA_BOUNDARY_THICKNESS_PX = 2 AREA_BOUNDARY_THICKNESS_PX, DEFAULT_LABEL_FONT_PATH, TRACK_COLOR_PALETTE = 2, None, ["#FF0000"]
TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)" GRATICULE_ENABLED, GRATICULE_LINE_COLOR, GRATICULE_LINE_WIDTH, GRATICULE_LABEL_FONT_SIZE = True, "gray", 1, 10
TILE_TEXT_COLOR = "white"
DEFAULT_LABEL_FONT_PATH = None
TRACK_COLOR_PALETTE = ["#FF0000", "#00FF00", "#0000FF"]
map_constants = MockMapConstants() map_constants = MockMapConstants()
TRACK_LINE_WIDTH = 2 TRACK_LINE_WIDTH = 2
def geo_to_pixel_on_unscaled_map(*args, **kwargs): return None def geo_to_pixel_on_unscaled_map(*args, **kwargs): return None
def calculate_graticule_interval(span, pixels): return 1.0
def draw_area_bounding_box( def draw_area_bounding_box(
@ -65,57 +63,94 @@ def draw_area_bounding_box(
if len(pixel_corners) == 4: if len(pixel_corners) == 4:
draw = ImageDraw.Draw(pil_image_to_draw_on) draw = ImageDraw.Draw(pil_image_to_draw_on)
# Close the polygon by adding the first point at the end
draw.line(pixel_corners + [pixel_corners[0]], fill=color, width=thickness) draw.line(pixel_corners + [pixel_corners[0]], fill=color, width=thickness)
return pil_image_to_draw_on return pil_image_to_draw_on
def _load_label_font(scaled_font_size: int) -> Optional[Any]: def draw_graticules(
draw: ImageDraw.ImageDraw,
map_geo_bounds: Tuple[float, float, float, float],
image_size: Tuple[int, int],
font: Optional[ImageFont.FreeTypeFont]
):
if not (PIL_LIB_AVAILABLE_DRAWING and draw and map_geo_bounds):
return
west, south, east, north = map_geo_bounds
lon_span, lat_span = east - west, north - south
img_width, img_height = image_size
# MODIFICATO: Passaggio dei parametri corretti alla funzione
lon_interval = calculate_graticule_interval(lon_span, img_width)
lat_interval = calculate_graticule_interval(lat_span, img_height)
line_color, line_width = map_constants.GRATICULE_LINE_COLOR, map_constants.GRATICULE_LINE_WIDTH
epsilon = 1e-9
start_lon = math.floor(west / lon_interval) * lon_interval
if start_lon < west - epsilon: start_lon += lon_interval
lon = start_lon
while lon < east + epsilon:
p1_pixel = geo_to_pixel_on_unscaled_map(north, lon, map_geo_bounds, image_size)
p2_pixel = geo_to_pixel_on_unscaled_map(south, lon, map_geo_bounds, image_size)
if p1_pixel and p2_pixel:
draw.line([p1_pixel, p2_pixel], fill=line_color, width=line_width)
label = f"{abs(round(lon, 4)):g}°{'E' if lon >= 0 else 'W'}"
label_pos = (p1_pixel[0] + 5, 5)
if font: draw.text(label_pos, label, font=font, fill=line_color, anchor="lt")
lon += lon_interval
start_lat = math.floor(south / lat_interval) * lat_interval
if start_lat < south - epsilon: start_lat += lat_interval
lat = start_lat
while lat < north + epsilon:
p1_pixel = geo_to_pixel_on_unscaled_map(lat, west, map_geo_bounds, image_size)
p2_pixel = geo_to_pixel_on_unscaled_map(lat, east, map_geo_bounds, image_size)
if p1_pixel and p2_pixel:
draw.line([p1_pixel, p2_pixel], fill=line_color, width=line_width)
label = f"{abs(round(lat, 4)):g}°{'N' if lat >= 0 else 'S'}"
label_pos = (5, p1_pixel[1] - 5)
if font: draw.text(label_pos, label, font=font, fill=line_color, anchor="lb")
lat += lat_interval
def _load_label_font(scaled_font_size: int, is_graticule: bool = False) -> Optional[Any]:
if not (PIL_LIB_AVAILABLE_DRAWING and ImageFont): if not (PIL_LIB_AVAILABLE_DRAWING and ImageFont):
return None return None
scaled_font_size = max(1, scaled_font_size)
font_size = map_constants.GRATICULE_LABEL_FONT_SIZE if is_graticule else max(1, scaled_font_size)
font_path = getattr(map_constants, "DEFAULT_LABEL_FONT_PATH", None) font_path = getattr(map_constants, "DEFAULT_LABEL_FONT_PATH", None)
if font_path:
try:
return ImageFont.truetype(font_path, scaled_font_size)
except (IOError, OSError):
logger.warning(f"Font file not found at {font_path}. Falling back.")
except Exception as e:
logger.error(f"Error loading TrueType font {font_path}: {e}")
try: try:
if font_path and os.path.exists(font_path): return ImageFont.truetype(font_path, font_size)
else: return ImageFont.load_default(size=font_size)
except (IOError, OSError): logger.warning(f"Font file not found at {font_path}. Falling back.")
except AttributeError: return ImageFont.load_default()
except Exception as e: logger.error(f"Error loading font: {e}")
return ImageFont.load_default() return ImageFont.load_default()
except Exception as e:
logger.error(f"Could not load default PIL font: {e}")
return None
def _calculate_faded_color(base_color_hex: str, index_from_end: int, total_visible_points: int, min_intensity: float = 0.1) -> Optional[Tuple[int, int, int, int]]: def _calculate_faded_color(base_color_hex: str, index_from_end: int, total_visible_points: int, min_intensity: float = 0.1) -> Optional[Tuple[int, int, int, int]]:
if not (0 <= index_from_end < total_visible_points and ImageColor): if not (0 <= index_from_end < total_visible_points and ImageColor): return None
return None
try: try:
r, g, b = ImageColor.getrgb(base_color_hex) r, g, b = ImageColor.getrgb(base_color_hex)
if total_visible_points <= 1: intensity = min_intensity + (1.0 - min_intensity) * ((total_visible_points - 1 - index_from_end) / (total_visible_points - 1)) if total_visible_points > 1 else 1.0
intensity = 1.0
else:
ratio = (total_visible_points - 1 - index_from_end) / (total_visible_points - 1)
intensity = min_intensity + (1.0 - min_intensity) * ratio
return (r, g, b, int(max(0, min(255, intensity * 255)))) return (r, g, b, int(max(0, min(255, intensity * 255))))
except Exception: except Exception: return (128, 128, 128, 255)
return (128, 128, 128, 255)
def _draw_single_flight( def _draw_single_flight(
draw: Any, draw: Any, pixel_coords: Tuple[int, int], flight_state: CanonicalFlightState, label_font: Optional[Any],
pixel_coords: Tuple[int, int], track_deque: Optional[deque], current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
flight_state: CanonicalFlightState,
label_font: Optional[Any],
track_deque: Optional[deque],
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
current_stitched_map_pixel_shape: Optional[Tuple[int, int]], current_stitched_map_pixel_shape: Optional[Tuple[int, int]],
): ):
if not (PIL_LIB_AVAILABLE_DRAWING and draw and pixel_coords and flight_state): if not (PIL_LIB_AVAILABLE_DRAWING and draw and pixel_coords and flight_state): return
return
px, py = pixel_coords px, py = pixel_coords
base_length, side_length = 8, 12 base_length, side_length = 8, 12
@ -125,25 +160,11 @@ def _draw_single_flight(
base_color = map_constants.TRACK_COLOR_PALETTE[color_index] base_color = map_constants.TRACK_COLOR_PALETTE[color_index]
if track_deque and len(track_deque) > 1: if track_deque and len(track_deque) > 1:
# Usiamo list() per creare una copia su cui iterare in sicurezza pixel_points = [p for p in [geo_to_pixel_on_unscaled_map(pt[0], pt[1], current_map_geo_bounds, current_stitched_map_pixel_shape) for pt in list(track_deque)] if p]
points_to_draw = list(track_deque)
pixel_points = [geo_to_pixel_on_unscaled_map(p[0], p[1], current_map_geo_bounds, current_stitched_map_pixel_shape) for p in points_to_draw]
pixel_points = [p for p in pixel_points if p is not None] # Filtra i punti non validi
# Log di debug per la traccia
if flight_state.icao24 == '300557': # Mantieni un ICAO di test
logger.info(f"--- Drawing track for {flight_state.icao24} with {len(points_to_draw)} points ---")
for i, point in enumerate(points_to_draw):
logger.info(f" Point {i}: ts={int(point[2])}, lat={point[0]:.4f}, lon={point[1]:.4f}")
logger.info("------------------------------------")
if len(pixel_points) > 1: if len(pixel_points) > 1:
for i in range(len(pixel_points) - 1): for i in range(len(pixel_points) - 1):
start_point = pixel_points[i]
end_point = pixel_points[i+1]
faded_color = _calculate_faded_color(base_color, len(pixel_points) - 2 - i, len(pixel_points)) faded_color = _calculate_faded_color(base_color, len(pixel_points) - 2 - i, len(pixel_points))
if faded_color: if faded_color: draw.line([pixel_points[i], pixel_points[i+1]], fill=faded_color, width=TRACK_LINE_WIDTH)
draw.line([start_point, end_point], fill=faded_color, width=TRACK_LINE_WIDTH)
angle_rad = math.radians(90 - (flight_state.true_track_deg or 0.0)) angle_rad = math.radians(90 - (flight_state.true_track_deg or 0.0))
tip = (px + side_length * math.cos(angle_rad), py - side_length * math.sin(angle_rad)) tip = (px + side_length * math.cos(angle_rad), py - side_length * math.sin(angle_rad))
@ -162,15 +183,13 @@ def _draw_single_flight(
except Exception as e: except Exception as e:
logger.error(f"Error drawing label for '{label_text}': {e}", exc_info=False) logger.error(f"Error drawing label for '{label_text}': {e}", exc_info=False)
def _draw_text_on_placeholder(draw: Any, image_size: Tuple[int, int], text: str): def _draw_text_on_placeholder(draw: Any, image_size: Tuple[int, int], text: str):
if not (PIL_LIB_AVAILABLE_DRAWING and draw): return if not (PIL_LIB_AVAILABLE_DRAWING and draw): return
img_w, img_h = image_size img_w, img_h = image_size
font = _load_label_font(12) font = _load_label_font(12, is_graticule=True)
if not font: if not font:
draw.text((10, 10), text, fill="black") draw.text((10, 10), text, fill="black")
return return
try: try:
bbox = draw.textbbox((0, 0), text, font=font, anchor="lt", spacing=4) bbox = draw.textbbox((0, 0), text, font=font, anchor="lt", spacing=4)
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1] text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]

View File

@ -8,7 +8,7 @@ try:
import pyproj import pyproj
PYPROJ_MODULE_LOCALLY_AVAILABLE = True PYPROJ_MODULE_LOCALLY_AVAILABLE = True
except ImportError: except ImportError:
pyproj = None # type: ignore pyproj = None
PYPROJ_MODULE_LOCALLY_AVAILABLE = False PYPROJ_MODULE_LOCALLY_AVAILABLE = False
logging.warning("MapUtils: 'pyproj' not found. Geographic calculations will be impaired.") logging.warning("MapUtils: 'pyproj' not found. Geographic calculations will be impaired.")
@ -16,7 +16,7 @@ try:
import mercantile import mercantile
MERCANTILE_MODULE_LOCALLY_AVAILABLE = True MERCANTILE_MODULE_LOCALLY_AVAILABLE = True
except ImportError: except ImportError:
mercantile = None # type: ignore mercantile = None
MERCANTILE_MODULE_LOCALLY_AVAILABLE = False MERCANTILE_MODULE_LOCALLY_AVAILABLE = False
logging.warning("MapUtils: 'mercantile' not found. Tile operations will fail.") logging.warning("MapUtils: 'mercantile' not found. Tile operations will fail.")
@ -34,11 +34,9 @@ try:
except ImportError: except ImportError:
logger.error("MapUtils: map_constants not found. Using mock constants.") logger.error("MapUtils: map_constants not found. Using mock constants.")
class MockMapConstants: class MockMapConstants:
DMS_DEGREE_SYMBOL = "°" DMS_DEGREE_SYMBOL, DMS_MINUTE_SYMBOL, DMS_SECOND_SYMBOL = "°", "'", "''"
DMS_MINUTE_SYMBOL = "'" MIN_ZOOM_LEVEL, DEFAULT_MAX_ZOOM_FALLBACK = 0, 20
DMS_SECOND_SYMBOL = "''" GRATICULE_MIN_PIXEL_SPACING = 80
MIN_ZOOM_LEVEL = 0
DEFAULT_MAX_ZOOM_FALLBACK = 20
map_constants = MockMapConstants() map_constants = MockMapConstants()
class MapCalculationError(Exception): class MapCalculationError(Exception):
@ -46,28 +44,53 @@ class MapCalculationError(Exception):
pass pass
def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool: def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool:
if not isinstance(bbox_dict, dict): if not isinstance(bbox_dict, dict): return False
return False
required_keys = ["lat_min", "lon_min", "lat_max", "lon_max"] required_keys = ["lat_min", "lon_min", "lat_max", "lon_max"]
if not all(key in bbox_dict for key in required_keys): if not all(key in bbox_dict for key in required_keys): return False
return False
try: try:
lat_min, lon_min = float(bbox_dict["lat_min"]), float(bbox_dict["lon_min"]) lat_min, lon_min = float(bbox_dict["lat_min"]), float(bbox_dict["lon_min"])
lat_max, lon_max = float(bbox_dict["lat_max"]), float(bbox_dict["lon_max"]) lat_max, lon_max = float(bbox_dict["lat_max"]), float(bbox_dict["lon_max"])
if not (-90.0 <= lat_min <= 90.0 and -90.0 <= lat_max <= 90.0 and if not (-90.0 <= lat_min <= 90.0 and -90.0 <= lat_max <= 90.0 and
-180.0 <= lon_min <= 180.0 and -180.0 <= lon_max <= 180.0): -180.0 <= lon_min <= 180.0 and -180.0 <= lon_max <= 180.0):
return False return False
if lat_min >= lat_max or lon_min >= lon_max: if lat_min >= lat_max or lon_min >= lon_max: return False
return False
return True return True
except (ValueError, TypeError): except (ValueError, TypeError): return False
return False
def calculate_graticule_interval(
span_degrees: float,
image_pixel_span: int,
min_pixel_spacing: Optional[int] = None
) -> float:
"""
Calculates a reasonable interval for graticule lines based on the map's
current scale, ensuring a minimum visual separation in pixels.
"""
if min_pixel_spacing is None:
min_pixel_spacing = map_constants.GRATICULE_MIN_PIXEL_SPACING
if span_degrees <= 0 or image_pixel_span <= 0:
return 5.0
degrees_per_pixel = span_degrees / image_pixel_span
min_degree_interval = degrees_per_pixel * min_pixel_spacing
possible_intervals = [
90, 45, 30, 20, 15, 10, 5, 2, 1,
0.5, 0.25, 0.1, 0.05, 0.02, 0.01,
0.005, 0.002, 0.001, 0.0005, 0.0001
]
for interval in reversed(possible_intervals):
if interval >= min_degree_interval:
return interval
return possible_intervals[0]
def get_bounding_box_from_center_size( def get_bounding_box_from_center_size(
center_latitude_deg: float, center_longitude_deg: float, area_size_km: float center_latitude_deg: float, center_longitude_deg: float, area_size_km: float
) -> Optional[Tuple[float, float, float, float]]: ) -> Optional[Tuple[float, float, float, float]]:
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: return None
return None
try: try:
geod = pyproj.Geod(ellps="WGS84") geod = pyproj.Geod(ellps="WGS84")
half_side_m = (area_size_km / 2.0) * 1000.0 half_side_m = (area_size_km / 2.0) * 1000.0
@ -83,15 +106,12 @@ def get_bounding_box_from_center_size(
def get_tile_ranges_for_bbox( def get_tile_ranges_for_bbox(
bounding_box_deg: Tuple[float, float, float, float], zoom_level: int bounding_box_deg: Tuple[float, float, float, float], zoom_level: int
) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]: ) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]:
if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or not mercantile: if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or not mercantile: return None
return None
try: try:
west, south, east, north = bounding_box_deg west, south, east, north = bounding_box_deg
tiles = list(mercantile.tiles(west, south, east, north, zooms=[zoom_level])) tiles = list(mercantile.tiles(west, south, east, north, zooms=[zoom_level]))
if not tiles: if not tiles: return None
return None x_coords, y_coords = [t.x for t in tiles], [t.y for t in tiles]
x_coords = [t.x for t in tiles]
y_coords = [t.y for t in tiles]
return ((min(x_coords), max(x_coords)), (min(y_coords), max(y_coords))) return ((min(x_coords), max(x_coords)), (min(y_coords), max(y_coords)))
except Exception as e: except Exception as e:
logger.exception(f"Error calculating tile ranges: {e}") logger.exception(f"Error calculating tile ranges: {e}")
@ -112,8 +132,7 @@ def calculate_meters_per_pixel(
def calculate_geographic_bbox_size_km( def calculate_geographic_bbox_size_km(
bounding_box_deg: Tuple[float, float, float, float] bounding_box_deg: Tuple[float, float, float, float]
) -> Optional[Tuple[float, float]]: ) -> Optional[Tuple[float, float]]:
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: return None
return None
try: try:
west, south, east, north = bounding_box_deg west, south, east, north = bounding_box_deg
geod = pyproj.Geod(ellps="WGS84") geod = pyproj.Geod(ellps="WGS84")
@ -128,6 +147,7 @@ def calculate_zoom_level_for_geographic_size(
latitude_degrees: float, geographic_span_meters: float, target_pixel_span: int, tile_pixel_size: int = 256 latitude_degrees: float, geographic_span_meters: float, target_pixel_span: int, tile_pixel_size: int = 256
) -> Optional[int]: ) -> Optional[int]:
try: try:
if target_pixel_span <= 0: return None
required_res = geographic_span_meters / target_pixel_span required_res = geographic_span_meters / target_pixel_span
EARTH_CIRCUMFERENCE_METERS = 40075016.686 EARTH_CIRCUMFERENCE_METERS = 40075016.686
clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees)) clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees))
@ -199,10 +219,9 @@ def geo_to_pixel_on_unscaled_map(
return (int(round(rel_x * img_w)), int(round(rel_y * img_h))) return (int(round(rel_x * img_w)), int(round(rel_y * img_h)))
except Exception as e: except Exception as e:
logger.error(f"Error converting geo to pixel: {e}", exc_info=True) logger.error(f"Error converting geo to pixel: {e}", exc_info=False)
return None return None
# Rinominata da _pixel_to_geo a pixel_to_geo
def pixel_to_geo( def pixel_to_geo(
canvas_x: int, canvas_x: int,
canvas_y: int, canvas_y: int,
@ -221,9 +240,7 @@ def pixel_to_geo(
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west, map_north, truncate=False) map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west, map_north, truncate=False)
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east, map_south, truncate=False) map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east, map_south, truncate=False)
total_merc_w = abs(map_lr_merc_x - map_ul_merc_x) total_merc_w, total_merc_h = abs(map_lr_merc_x - map_ul_merc_x), abs(map_ul_merc_y - map_lr_merc_y)
total_merc_h = abs(map_ul_merc_y - map_lr_merc_y)
if total_merc_w < 1e-9 or total_merc_h < 1e-9: return None, None if total_merc_w < 1e-9 or total_merc_h < 1e-9: return None, None
rel_x, rel_y = canvas_x / img_w, canvas_y / img_h rel_x, rel_y = canvas_x / img_w, canvas_y / img_h