diff --git a/flightmonitor/map/map_canvas_manager.py b/flightmonitor/map/map_canvas_manager.py index 41b8d56..a68e7a6 100644 --- a/flightmonitor/map/map_canvas_manager.py +++ b/flightmonitor/map/map_canvas_manager.py @@ -8,6 +8,7 @@ from collections import deque import queue import threading import copy +import os # Aggiunto per _load_label_font try: from PIL import Image, ImageTk, ImageDraw, ImageFont @@ -120,6 +121,9 @@ class MapCanvasManager: return None, None, "Failed to stitch map" 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) if not center_px: center_px = (stitched_map.width / 2, stitched_map.height / 2) @@ -134,24 +138,39 @@ class MapCanvasManager: if not final_bounds: return None, None, "Failed to calculate final map bounds" - image_to_draw = final_image.convert("RGBA") - draw = ImageDraw.Draw(image_to_draw) + # --- MODIFICA CHIAVE: Logica di disegno con overlay trasparente --- + # 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): 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: - 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: 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: 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 ) + + # 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) - return ImageTk.PhotoImage(image_to_draw), final_bounds, None + # 4. Crea il PhotoImage dall'immagine composita finale + return ImageTk.PhotoImage(composite_image), final_bounds, None + # --- FINE MODIFICA --- def _start_gui_result_processing(self): if self._gui_after_id_result_processor and self.canvas.winfo_exists(): @@ -214,6 +233,9 @@ class MapCanvasManager: self.canvas.bind("", self._on_mouse_drag) self.canvas.bind("", self._on_left_button_release) self.canvas.bind("", self._on_right_click) + self.canvas.bind("", self._on_mouse_wheel) + self.canvas.bind("", self._on_mouse_wheel_linux) + self.canvas.bind("", self._on_mouse_wheel_linux) self._drag_start_x_canvas, self._drag_start_y_canvas = None, None def _on_canvas_resize(self, event: tk.Event): @@ -259,10 +281,19 @@ class MapCanvasManager: if lon is not None and lat is not None: 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 + + 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]: - if not self._current_map_geo_bounds_gui or not self._map_photo_image: - return None + if not self._current_map_geo_bounds_gui or not self._map_photo_image: return None min_dist_sq, clicked_icao = float('inf'), None radius_sq = 15**2 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: size_km = map_utils.calculate_geographic_bbox_size_km((bbox["lon_min"], bbox["lat_min"], bbox["lon_max"], bbox["lat_max"])) 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_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 @@ -321,15 +352,12 @@ class MapCanvasManager: def _clear_canvas_display_elements(self): if self.canvas.winfo_exists(): - if 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._canvas_image_id: self.canvas.delete(self._canvas_image_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 def _display_placeholder_text(self, text: str): - if not self.canvas.winfo_exists(): - return + if not self.canvas.winfo_exists(): return self._clear_canvas_display_elements() 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 @@ -372,8 +400,7 @@ class MapCanvasManager: self.map_render_manager.shutdown_worker() def recenter_map_at_coords(self, lat: float, lon: float): - if self._current_zoom_gui is not None: - self._request_map_render(lat, lon, self._current_zoom_gui) + if self._current_zoom_gui is not None: self._request_map_render(lat, lon, self._current_zoom_gui) 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) @@ -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) 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]): - return + 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 res = map_utils.calculate_meters_per_pixel(self._current_center_lat_gui, self._current_zoom_gui, self.tile_manager.tile_size) - if not res: - return + if not res: return dx, dy = 0, 0 if direction == "left": dx = -self.canvas_width * PAN_STEP_FRACTION elif direction == "right": dx = self.canvas_width * PAN_STEP_FRACTION diff --git a/flightmonitor/map/map_constants.py b/flightmonitor/map/map_constants.py index 59cccd9..de48d62 100644 --- a/flightmonitor/map/map_constants.py +++ b/flightmonitor/map/map_constants.py @@ -7,139 +7,52 @@ from typing import Optional, Tuple # --- 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_THICKNESS_PX: int = 2 -# Colors for text labels drawn on tiles (e.g., DEM tile names). 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)" # Semi-transparent black background +TILE_TEXT_BG_COLOR: str = "rgba(0, 0, 0, 150)" + +# --- 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 --- -# 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 - # --- Map Behavior Constants --- - -# Default zoom level when the map is initialized or reset if no BBox is set. DEFAULT_INITIAL_ZOOM: int = 7 - -# Minimum and maximum allowed zoom levels for interactive pan/zoom. 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 - # --- Placeholders and Fallbacks --- - -# Default color for placeholder tile images when a tile cannot be loaded. -DEFAULT_PLACEHOLDER_COLOR_RGB: Tuple[int, int, int] = (220, 220, 220) # Light grey - -# MODIFIED: Added constant for Tkinter canvas background color for placeholders. -# WHY: Needed by MapCanvasManager to set the canvas background when displaying placeholder text. -# HOW: Added the new constant definition. -DEFAULT_PLACEHOLDER_COLOR_RGB_TK: str = ( - "gray85" # Tkinter color string for canvas background -) +DEFAULT_PLACEHOLDER_COLOR_RGB: Tuple[int, int, int] = (220, 220, 220) +DEFAULT_PLACEHOLDER_COLOR_RGB_TK: str = "gray85" # --- Map Information Panel Formatting --- - -# Number of decimal places to display for coordinates in the info panel. 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 - -# Define standard degree, minute, second symbols for DMS formatting +# --- Simboli DMS --- DMS_DEGREE_SYMBOL: str = "°" DMS_MINUTE_SYMBOL: str = "'" DMS_SECOND_SYMBOL: str = "''" -# MODIFIED: Paletta colori -# WHY: Paletta ampliata -# HOW: Ampliata la lista dei colori +# Palette colori per le tracce TRACK_COLOR_PALETTE = [ - "#FF0000", - "#00FF00", - "#0000FF", - "#FFFF00", - "#FF00FF", - "#00FFFF", - "#800000", - "#008000", - "#000080", - "#808000", - "#800080", - "#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 -""" + "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", + "#800000", "#008000", "#000080", "#808000", "#800080", "#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", +] \ No newline at end of file diff --git a/flightmonitor/map/map_drawing.py b/flightmonitor/map/map_drawing.py index 0d54893..f6fddfc 100644 --- a/flightmonitor/map/map_drawing.py +++ b/flightmonitor/map/map_drawing.py @@ -4,6 +4,7 @@ import logging import math from typing import Optional, Tuple, List, Dict, Any from collections import deque +import os try: from PIL import Image, ImageDraw, ImageFont, ImageColor @@ -15,29 +16,26 @@ except ImportError: logging.error("MapDrawing: Pillow not found. Drawing disabled.") 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 . import map_constants from ..data import config as app_config logger = logging.getLogger(__name__) - TRACK_LINE_WIDTH = getattr(app_config, "DEFAULT_TRACK_LINE_WIDTH", 2) + TRACK_LINE_WIDTH = 2 except ImportError as e: logger = logging.getLogger(__name__) logger.error(f"MapDrawing failed to import dependencies: {e}. Using fallbacks.") - # Fallback classes and constants if imports fail class CanonicalFlightState: pass class MockMapConstants: - AREA_BOUNDARY_COLOR = "blue" - AREA_BOUNDARY_THICKNESS_PX = 2 - TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)" - TILE_TEXT_COLOR = "white" - DEFAULT_LABEL_FONT_PATH = None - TRACK_COLOR_PALETTE = ["#FF0000", "#00FF00", "#0000FF"] + AREA_BOUNDARY_COLOR, TILE_TEXT_BG_COLOR, TILE_TEXT_COLOR = "blue", "rgba(0, 0, 0, 150)", "white" + AREA_BOUNDARY_THICKNESS_PX, DEFAULT_LABEL_FONT_PATH, TRACK_COLOR_PALETTE = 2, None, ["#FF0000"] + GRATICULE_ENABLED, GRATICULE_LINE_COLOR, GRATICULE_LINE_WIDTH, GRATICULE_LABEL_FONT_SIZE = True, "gray", 1, 10 map_constants = MockMapConstants() TRACK_LINE_WIDTH = 2 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( @@ -65,57 +63,94 @@ def draw_area_bounding_box( if len(pixel_corners) == 4: 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) 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): 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) - 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: - return ImageFont.load_default() - except Exception as e: - logger.error(f"Could not load default PIL font: {e}") - return None - + 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() 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): - return None + if not (0 <= index_from_end < total_visible_points and ImageColor): return None try: r, g, b = ImageColor.getrgb(base_color_hex) - if total_visible_points <= 1: - intensity = 1.0 - else: - ratio = (total_visible_points - 1 - index_from_end) / (total_visible_points - 1) - intensity = min_intensity + (1.0 - min_intensity) * ratio + 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 return (r, g, b, int(max(0, min(255, intensity * 255)))) - except Exception: - return (128, 128, 128, 255) + except Exception: return (128, 128, 128, 255) def _draw_single_flight( - draw: Any, - pixel_coords: Tuple[int, int], - flight_state: CanonicalFlightState, - label_font: Optional[Any], - track_deque: Optional[deque], - current_map_geo_bounds: Optional[Tuple[float, float, float, float]], + draw: Any, pixel_coords: Tuple[int, int], 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]], ): - if not (PIL_LIB_AVAILABLE_DRAWING and draw and pixel_coords and flight_state): - return + if not (PIL_LIB_AVAILABLE_DRAWING and draw and pixel_coords and flight_state): return px, py = pixel_coords base_length, side_length = 8, 12 @@ -125,25 +160,11 @@ def _draw_single_flight( base_color = map_constants.TRACK_COLOR_PALETTE[color_index] if track_deque and len(track_deque) > 1: - # Usiamo list() per creare una copia su cui iterare in sicurezza - 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("------------------------------------") - + 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] if 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)) - if faded_color: - draw.line([start_point, end_point], fill=faded_color, width=TRACK_LINE_WIDTH) + if faded_color: draw.line([pixel_points[i], pixel_points[i+1]], fill=faded_color, width=TRACK_LINE_WIDTH) 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)) @@ -162,15 +183,13 @@ def _draw_single_flight( except Exception as e: 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): if not (PIL_LIB_AVAILABLE_DRAWING and draw): return img_w, img_h = image_size - font = _load_label_font(12) + font = _load_label_font(12, is_graticule=True) if not font: draw.text((10, 10), text, fill="black") return - try: bbox = draw.textbbox((0, 0), text, font=font, anchor="lt", spacing=4) text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1] diff --git a/flightmonitor/map/map_utils.py b/flightmonitor/map/map_utils.py index c75ac7e..d15512c 100644 --- a/flightmonitor/map/map_utils.py +++ b/flightmonitor/map/map_utils.py @@ -8,7 +8,7 @@ try: import pyproj PYPROJ_MODULE_LOCALLY_AVAILABLE = True except ImportError: - pyproj = None # type: ignore + pyproj = None PYPROJ_MODULE_LOCALLY_AVAILABLE = False logging.warning("MapUtils: 'pyproj' not found. Geographic calculations will be impaired.") @@ -16,7 +16,7 @@ try: import mercantile MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError: - mercantile = None # type: ignore + mercantile = None MERCANTILE_MODULE_LOCALLY_AVAILABLE = False logging.warning("MapUtils: 'mercantile' not found. Tile operations will fail.") @@ -34,11 +34,9 @@ try: except ImportError: logger.error("MapUtils: map_constants not found. Using mock constants.") class MockMapConstants: - DMS_DEGREE_SYMBOL = "°" - DMS_MINUTE_SYMBOL = "'" - DMS_SECOND_SYMBOL = "''" - MIN_ZOOM_LEVEL = 0 - DEFAULT_MAX_ZOOM_FALLBACK = 20 + DMS_DEGREE_SYMBOL, DMS_MINUTE_SYMBOL, DMS_SECOND_SYMBOL = "°", "'", "''" + MIN_ZOOM_LEVEL, DEFAULT_MAX_ZOOM_FALLBACK = 0, 20 + GRATICULE_MIN_PIXEL_SPACING = 80 map_constants = MockMapConstants() class MapCalculationError(Exception): @@ -46,28 +44,53 @@ class MapCalculationError(Exception): pass def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool: - if not isinstance(bbox_dict, dict): - return False + if not isinstance(bbox_dict, dict): return False required_keys = ["lat_min", "lon_min", "lat_max", "lon_max"] - if not all(key in bbox_dict for key in required_keys): - return False + if not all(key in bbox_dict for key in required_keys): return False try: 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"]) 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): return False - if lat_min >= lat_max or lon_min >= lon_max: - return False + if lat_min >= lat_max or lon_min >= lon_max: return False return True - except (ValueError, TypeError): - return False + except (ValueError, TypeError): 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( center_latitude_deg: float, center_longitude_deg: float, area_size_km: float ) -> Optional[Tuple[float, float, float, float]]: - if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: - return None + if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: return None try: geod = pyproj.Geod(ellps="WGS84") 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( bounding_box_deg: Tuple[float, float, float, float], zoom_level: int ) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]: - if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or not mercantile: - return None + if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or not mercantile: return None try: west, south, east, north = bounding_box_deg tiles = list(mercantile.tiles(west, south, east, north, zooms=[zoom_level])) - if not tiles: - return None - x_coords = [t.x for t in tiles] - y_coords = [t.y for t in tiles] + if not tiles: return None + x_coords, y_coords = [t.x for t in tiles], [t.y for t in tiles] return ((min(x_coords), max(x_coords)), (min(y_coords), max(y_coords))) except Exception as e: logger.exception(f"Error calculating tile ranges: {e}") @@ -112,8 +132,7 @@ def calculate_meters_per_pixel( def calculate_geographic_bbox_size_km( bounding_box_deg: Tuple[float, float, float, float] ) -> Optional[Tuple[float, float]]: - if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: - return None + if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: return None try: west, south, east, north = bounding_box_deg 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 ) -> Optional[int]: try: + if target_pixel_span <= 0: return None required_res = geographic_span_meters / target_pixel_span EARTH_CIRCUMFERENCE_METERS = 40075016.686 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))) 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 -# Rinominata da _pixel_to_geo a pixel_to_geo def pixel_to_geo( canvas_x: 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_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_h = abs(map_ul_merc_y - map_lr_merc_y) - + total_merc_w, total_merc_h = abs(map_lr_merc_x - map_ul_merc_x), abs(map_ul_merc_y - map_lr_merc_y) 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