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 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
)
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):
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("<ButtonRelease-1>", self._on_left_button_release)
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
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._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

View File

@ -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",
"#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
"""

View File

@ -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:
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()
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]]:
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]

View File

@ -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