# FlightMonitor/map/map_utils.py import logging import math from typing import Tuple, Optional, List, Set, Dict, Any try: import pyproj PYPROJ_MODULE_LOCALLY_AVAILABLE = True except ImportError: pyproj = None PYPROJ_MODULE_LOCALLY_AVAILABLE = False logging.warning("MapUtils: 'pyproj' not found. Geographic calculations will be impaired.") try: import mercantile MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError: mercantile = None MERCANTILE_MODULE_LOCALLY_AVAILABLE = False logging.warning("MapUtils: 'mercantile' not found. Tile operations will fail.") try: from ..utils.logger import get_logger logger = get_logger(__name__) except ImportError: logger = logging.getLogger(__name__) if not logger.hasHandlers(): logging.basicConfig(level=logging.DEBUG) logger.warning("MapUtils using fallback standard Python logger.") try: from . import map_constants 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, DEFAULT_MAX_ZOOM_FALLBACK = 0, 20 GRATICULE_MIN_PIXEL_SPACING = 80 map_constants = MockMapConstants() class MapCalculationError(Exception): """Custom exception for map calculations.""" pass def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool: 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 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 return True 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 try: geod = pyproj.Geod(ellps="WGS84") half_side_m = (area_size_km / 2.0) * 1000.0 _, north_lat, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 0.0, half_side_m) _, south_lat, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 180.0, half_side_m) east_lon, _, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 90.0, half_side_m) west_lon, _, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 270.0, half_side_m) return (west_lon, south_lat, east_lon, north_lat) except Exception as e: logger.exception(f"Error calculating BBox from center/size: {e}") return None 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 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, 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}") return None def calculate_meters_per_pixel( latitude_degrees: float, zoom_level: int, tile_pixel_size: int = 256 ) -> Optional[float]: try: EARTH_CIRCUMFERENCE_METERS = 40075016.686 clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees)) resolution = (EARTH_CIRCUMFERENCE_METERS * math.cos(math.radians(clamped_lat))) / (tile_pixel_size * (2**zoom_level)) return resolution except Exception as e: logger.exception(f"Error calculating meters per pixel: {e}") return None 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 try: west, south, east, north = bounding_box_deg geod = pyproj.Geod(ellps="WGS84") _, _, width_m = geod.inv(west, (south+north)/2, east, (south+north)/2) _, _, height_m = geod.inv((west+east)/2, south, (west+east)/2, north) return (abs(width_m) / 1000.0, abs(height_m) / 1000.0) except Exception as e: logger.exception(f"Error calculating BBox size: {e}") return None 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)) term = (EARTH_CIRCUMFERENCE_METERS * math.cos(math.radians(clamped_lat))) / (tile_pixel_size * required_res) if term <= 0: return None precise_zoom = math.log2(term) return max(map_constants.MIN_ZOOM_LEVEL, min(int(round(precise_zoom)), map_constants.DEFAULT_MAX_ZOOM_FALLBACK)) except Exception as e: logger.exception(f"Error calculating zoom level: {e}") return None def deg_to_dms_string(degree_value: float, coord_type: str) -> str: if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value): return "N/A" abs_val = abs(degree_value) degrees = int(abs_val) minutes = int((abs_val - degrees) * 60) seconds = (((abs_val - degrees) * 60) - minutes) * 60 if coord_type.lower() == "lat": suffix = "N" if degree_value >= 0 else "S" elif coord_type.lower() == "lon": suffix = "E" if degree_value >= 0 else "W" else: suffix = "" return (f"{degrees}{map_constants.DMS_DEGREE_SYMBOL} " f"{minutes:02d}{map_constants.DMS_MINUTE_SYMBOL} " f"{seconds:05.2f}{map_constants.DMS_SECOND_SYMBOL} {suffix}") def calculate_geographic_bbox_from_pixel_size_and_zoom( center_latitude_deg: float, center_longitude_deg: float, target_pixel_width: int, target_pixel_height: int, zoom_level: int, tile_pixel_size: int = 256 ) -> Optional[Tuple[float, float, float, float]]: if not (MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile): return None try: res = calculate_meters_per_pixel(center_latitude_deg, zoom_level, tile_pixel_size) if not res: return None center_merc_x, center_merc_y = mercantile.xy(center_longitude_deg, center_latitude_deg) half_w_merc, half_h_merc = (target_pixel_width/2) * res, (target_pixel_height/2) * res ul_x, ul_y = center_merc_x - half_w_merc, center_merc_y + half_h_merc lr_x, lr_y = center_merc_x + half_w_merc, center_merc_y - half_h_merc west, north = mercantile.lnglat(ul_x, ul_y) east, south = mercantile.lnglat(lr_x, lr_y) return (west, south, east, north) except Exception as e: logger.exception(f"Error calculating geo BBox from pixel size/zoom: {e}") return None def geo_to_pixel_on_unscaled_map( latitude_deg: float, longitude_deg: float, current_map_geo_bounds: Optional[Tuple[float, float, float, float]], current_stitched_map_pixel_shape: Optional[Tuple[int, int]], ) -> Optional[Tuple[int, int]]: if not (MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile): return None if not (current_map_geo_bounds and current_stitched_map_pixel_shape): return None img_w, img_h = current_stitched_map_pixel_shape if img_w <= 0 or img_h <= 0: return None map_west, map_south, map_east, map_north = current_map_geo_bounds try: map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west, map_north) map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east, map_south) merc_w, merc_h = abs(map_lr_merc_x - map_ul_merc_x), abs(map_ul_merc_y - map_lr_merc_y) if merc_w < 1e-9 or merc_h < 1e-9: return None target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) rel_x = (target_merc_x - map_ul_merc_x) / merc_w rel_y = (map_ul_merc_y - target_merc_y) / merc_h 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=False) return None def pixel_to_geo( canvas_x: int, canvas_y: int, current_map_geo_bounds: Optional[Tuple[float, float, float, float]], current_stitched_map_pixel_shape: Optional[Tuple[int, int]], ) -> Tuple[Optional[float], Optional[float]]: if not (MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile): return None, None if not (current_map_geo_bounds and current_stitched_map_pixel_shape): return None, None img_w, img_h = current_stitched_map_pixel_shape if img_w <= 0 or img_h <= 0: return None, None map_west, map_south, map_east, map_north = current_map_geo_bounds try: 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, 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 target_merc_x = map_ul_merc_x + (rel_x * total_merc_w) target_merc_y = map_ul_merc_y - (rel_y * total_merc_h) lon, lat = mercantile.lnglat(target_merc_x, target_merc_y, truncate=False) return lon, lat except Exception as e: logger.error(f"Error converting pixel to geo: {e}", exc_info=True) return None, None