# 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 import logging logging.warning("MapUtils: 'pyproj' not found.") try: import mercantile MERCANTILE_MODULE_LOCALLY_AVAILABLE = True except ImportError: mercantile = None MERCANTILE_MODULE_LOCALLY_AVAILABLE = False import logging logging.warning("MapUtils: 'mercantile' not found.") try: from ..utils.logger import get_logger from . import map_constants except ImportError: logger = logging.getLogger(__name__) logger.warning("MapUtils: App logger or map_constants not available.") class MockMapConstants: COORDINATE_DECIMAL_PLACES = 4 DMS_DEGREE_SYMBOL = " deg" DMS_MINUTE_SYMBOL = " min" DMS_SECOND_SYMBOL = " sec" map_constants = MockMapConstants() logger = get_logger(__name__) class MapCalculationError(Exception): """Custom exception for map calculations.""" pass def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool: """Validate bounding box dict structure and ranges.""" if not isinstance(bbox_dict, dict): logger.debug("BBox validation failed: not 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): logger.debug(f"BBox validation failed: missing keys {required_keys}.") return False try: lat_min = float(bbox_dict["lat_min"]) lon_min = float(bbox_dict["lon_min"]) lat_max = float(bbox_dict["lat_max"]) lon_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): logger.debug(f"BBox validation failed: coordinates out of range {bbox_dict}.") return False if lat_min >= lat_max: logger.debug(f"BBox validation failed: lat_min ({lat_min}) >= lat_max ({lat_max}).") return False # Note: For services not supporting antimeridian crossing, lon_min must be < lon_max. # Current validation keeps this check for OpenSky compatibility. if lon_min >= lon_max: logger.debug(f"BBox validation failed: lon_min ({lon_min}) >= lon_max ({lon_max}).") return False if not all(math.isfinite(coord) for coord in [lat_min, lon_min, lat_max, lon_max]): logger.debug(f"BBox validation failed: non-finite values {bbox_dict}.") return False return True except (ValueError, TypeError) as e: logger.debug(f"BBox validation failed: invalid types/values {bbox_dict}. Error: {e}") return False except Exception as e: logger.error(f"Unexpected error validating BBox: {bbox_dict}. Error: {e}", exc_info=True) return False 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] ]: """Calc WGS84 BBox from center and square size (km). Requires pyproj.""" if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("'pyproj' required for BBox from center/size.") return None if not (isinstance(area_size_km, (int, float)) and area_size_km > 0): logger.error(f"Invalid area_size_km: {area_size_km}.") return None if not (isinstance(center_latitude_deg, (int, float)) and -90.0 <= center_latitude_deg <= 90.0): logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}.") return None if not (isinstance(center_longitude_deg, (int, float)) and -180.0 <= center_longitude_deg <= 180.0): logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}.") return None try: geodetic_calculator = pyproj.Geod(ellps="WGS84") half_side_m = (area_size_km / 2.0) * 1000.0 _, north_lat, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 0.0, half_side_m) _, south_lat, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 180.0, half_side_m) east_lon, _, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 90.0, half_side_m) west_lon, _, _ = geodetic_calculator.fwd(center_longitude_deg, center_latitude_deg, 270.0, half_side_m) north_lat = min(90.0, max(-90.0, north_lat)) south_lat = min(90.0, max(-90.0, south_lat)) if not all(math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat]): logger.error("Calculated BBox contains non-finite values.") return None return (west_lon, south_lat, east_lon, north_lat) except Exception as e_bbox_calc: logger.exception(f"Error calculating BBox from center/size: {e_bbox_calc}") 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]] ]: """Calc X/Y tile ranges covering BBox at zoom. Requires mercantile.""" if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: logger.error("'mercantile' required for tile ranges.") return None west_lon, south_lat, east_lon, north_lat = bounding_box_deg clamped_south_lat = max(-85.05112877, min(85.05112877, south_lat)) clamped_north_lat = max(-85.05112877, min(85.05112877, north_lat)) if clamped_south_lat >= clamped_north_lat: logger.warning(f"Clamped lat range invalid: S={clamped_south_lat:.4f}, N={clamped_north_lat:.4f}. Cannot get tile ranges.") return None if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): logger.error(f"Invalid zoom level: {zoom_level}.") return None try: # mercantile.tiles handles longitude wrapping automatically tiles_in_bbox_generator = mercantile.tiles(west_lon, clamped_south_lat, east_lon, clamped_north_lat, zooms=[zoom_level]) list_of_tiles = list(tiles_in_bbox_generator) if not list_of_tiles: logger.warning(f"No tiles found by mercantile for BBox at zoom {zoom_level}. Using fallback: tile at BBox center.") center_lon = (west_lon + east_lon) / 2.0 # Handle antimeridian crossing for center longitude if west_lon > east_lon: center_lon = (west_lon + east_lon + 360) / 2.0 if center_lon > 180: center_lon -= 360 center_lat = (south_lat + north_lat) / 2.0 center_lat = max(-85.05112877, min(85.05112877, center_lat)) try: center_tile = mercantile.tile(center_lon, center_lat, zoom_level) min_x, max_x = center_tile.x, center_tile.x min_y, max_y = center_tile.y, center_tile.y logger.debug(f"Fallback: Using tile at BBox center ({center_lon:.4f}, {center_lat:.4f}): ({min_x}, {min_y}, {zoom_level}).") except Exception as e_center_tile: logger.error(f"Fallback failed: Error getting tile for BBox center: {e_center_tile}", exc_info=True) return None else: x_coordinates = [tile.x for tile in list_of_tiles] y_coordinates = [tile.y for tile in list_of_tiles] min_x, max_x = min(x_coordinates), max(x_coordinates) min_y, max_y = min(y_coordinates), max(y_coordinates) return ((min_x, max_x), (min_y, max_y)) except Exception as e_tile_range_calc: logger.exception(f"Error calculating tile ranges: {e_tile_range_calc}") return None def calculate_meters_per_pixel( latitude_degrees: float, zoom_level: int, tile_pixel_size: int = 256 ) -> Optional[float]: """Calc approx ground resolution (m/px) at lat/zoom (Web Mercator).""" try: if not (isinstance(latitude_degrees, (int, float)) and -90.0 <= latitude_degrees <= 90.0): logger.warning(f"Invalid latitude for m/px calc: {latitude_degrees}") return None if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): logger.warning(f"Invalid zoom level for m/px calc: {zoom_level}") return None if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): logger.warning(f"Invalid tile_pixel_size for m/px calc: {tile_pixel_size}") return None EARTH_CIRCUMFERENCE_METERS = 40075016.686 clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees)) lat_radians = math.radians(clamped_lat) denominator = tile_pixel_size * (2**zoom_level) if denominator <= 1e-9: logger.warning("Denominator for m/px calc is zero/near-zero.") return None resolution_m_px = (EARTH_CIRCUMFERENCE_METERS * math.cos(lat_radians)) / denominator if not math.isfinite(resolution_m_px) or resolution_m_px <= 0: logger.warning(f"Calculated non-finite/non-positive m/px ({resolution_m_px}) at Lat {latitude_degrees}.") return None return resolution_m_px except Exception as e_mpp_calc: logger.exception(f"Error calculating meters per pixel: {e_mpp_calc}") return None def calculate_geographic_bbox_size_km( bounding_box_deg: Tuple[float, float, float, float], ) -> Optional[Tuple[float, float]]: """Calc approx W/H (km) of BBox. Requires pyproj.""" if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("'pyproj' required for BBox size.") return None west_lon, south_lat, east_lon, north_lat = bounding_box_deg if not (isinstance(west_lon, (int, float)) and isinstance(south_lat, (int, float)) and isinstance(east_lon, (int, float)) and isinstance(north_lat, (int, float))): logger.warning(f"Invalid coordinate types for size calc: {bounding_box_deg}.") return None if not (-90.0 <= south_lat <= north_lat <= 90.0): logger.warning(f"Invalid lat range for size calc: {south_lat}, {north_lat}. Clamping.") south_lat = max(-90.0, south_lat); north_lat = min(90.0, north_lat) if south_lat >= north_lat: logger.error(f"Invalid lat range after clamping: {south_lat}, {north_lat}."); return None try: geod = pyproj.Geod(ellps="WGS84") center_lat = (south_lat + north_lat) / 2.0 center_lat = max(-89.9, min(89.9, center_lat)) # Calculate width along the center latitude. Handles antimeridian crossing correctly in pyproj.Geod.inv. _, _, width_m = geod.inv(west_lon, center_lat, east_lon, center_lat) width_m = abs(width_m) # Calculate height along the center longitude (or 0/180 meridian if BBox crosses). # For simplicity, using center longitude here. center_lon_for_height = (west_lon + east_lon) / 2.0 if west_lon > east_lon: center_lon_for_height = (west_lon + east_lon + 360) / 2.0 if center_lon_for_height > 180: center_lon_for_height -= 360 center_lon_for_height = max(-179.9, min(179.9, center_lon_for_height)) # Clamp for geod.inv _, _, height_m = geod.inv(center_lon_for_height, south_lat, center_lon_for_height, north_lat) height_m = abs(height_m) approx_width_km = width_m / 1000.0 approx_height_km = height_m / 1000.0 if (approx_width_km <= 1e-9 or approx_height_km <= 1e-9): logger.warning(f"Calc non-positive W/H for BBox {bounding_box_deg}: ({approx_width_km:.6f}, {approx_height_km:.6f}).") return None if not all(math.isfinite(val) for val in [approx_width_km, approx_height_km]): logger.warning(f"Calc non-finite W/H: ({approx_width_km}, {approx_height_km}).") return None return (approx_width_km, approx_height_km) except Exception as e_size_calc: logger.exception(f"Error calculating BBox size: {e_size_calc}") return None def get_hgt_tile_geographic_bounds( lat_coord: int, lon_coord: int ) -> Optional[Tuple[float, float, float, float]]: """Calc WGS84 BBox (W,S,E,N) for HGT tile integer coords (S, W).""" if not (isinstance(lat_coord, int) and isinstance(lon_coord, int)): logger.warning(f"Invalid input types for HGT bounds: {type(lat_coord)}, {type(lon_coord)}.") return None # Note: HGT tile coordinates are typically S/W corner integers, e.g., N007E008 is lat_coord=7, lon_coord=8. # But ranges are [-90, 89] for lat, [-180, 179] for lon based on standard grid. if not (-90 <= lat_coord <= 89): logger.warning(f"Lat coord {lat_coord} outside expected HGT range [-90, 89].") if not (-180 <= lon_coord <= 179): logger.warning(f"Lon coord {lon_coord} outside expected HGT range [-180, 179].") west_lon = float(lon_coord) south_lat = float(lat_coord) east_lon = float(lon_coord + 1) north_lat = float(lat_coord + 1) # Clamp to valid WGS84 ranges defensively west_lon = max(-180.0, west_lon); south_lat = max(-90.0, south_lat); east_lon = min(180.0, east_lon); north_lat = min(90.0, north_lat) if south_lat >= north_lat: logger.warning(f"Calc HGT bounds have invalid lat range: S={south_lat}, N={north_lat}."); return None # Longitude wrapping for a single HGT tile shouldn't happen unless input coords are out of range initially. # The check `west_lon > east_lon` is only meaningful if not crossing antimeridian. # For HGT tiles, they represent 1x1 degree squares not crossing antimeridian. # if west_lon > east_lon and abs(west_lon - east_lon) > 1e-9: logger.warning(f"Calc HGT bounds have W > E: W={west_lon}, E={east_lon}."); return None return (west_lon, south_lat, east_lon, north_lat) def calculate_zoom_level_for_geographic_size( latitude_degrees: float, geographic_height_meters: float, target_pixel_height: int, tile_pixel_size: int = 256, ) -> Optional[int]: """Calc Web Mercator zoom for geographic height to fit pixel height.""" if not (isinstance(latitude_degrees, (int, float)) and -90.0 <= latitude_degrees <= 90.0): logger.warning(f"Invalid latitude for zoom calc: {latitude_degrees}") return None if not (isinstance(geographic_height_meters, (int, float)) and geographic_height_meters > 0): logger.warning(f"Invalid geographic_height_meters: {geographic_height_meters}.") return None if not (isinstance(target_pixel_height, int) and target_pixel_height > 0): logger.warning(f"Invalid target_pixel_height: {target_pixel_height}.") return None if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): logger.warning(f"Invalid tile_pixel_size: {tile_pixel_size}.") return None try: req_res_m_px = geographic_height_meters / target_pixel_height if req_res_m_px <= 1e-9 or not math.isfinite(req_res_m_px): logger.warning(f"Calc non-positive/non-finite required resolution ({req_res_m_px}).") return None EARTH_CIRCUMFERENCE_METERS = 40075016.686 clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees)) lat_radians = math.radians(clamped_lat) cos_lat = math.cos(lat_radians) if abs(cos_lat) < 1e-9: logger.warning(f"Latitude {latitude_degrees} too close to pole for reliable zoom calc.") return None if tile_pixel_size <= 0: logger.error("Invalid tile_pixel_size (<= 0)."); return None # res_m_px = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * 2^Z) # 2^Z = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * req_res_m_px) # Z = log2(...) term_for_log = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * req_res_m_px) if term_for_log <= 1e-9: logger.warning(f"Calc non-positive term for log2 ({term_for_log:.2e})."); return None precise_zoom = math.log2(term_for_log) integer_zoom = int(round(precise_zoom)) clamped_zoom = max(0, min(integer_zoom, 20)) # Clamp to 0-20 range (arbitrary upper cap, services vary) return clamped_zoom except Exception as e_zoom_calc: logger.exception(f"Error calculating zoom level: {e_zoom_calc}") return None def deg_to_dms_string(degree_value: float, coord_type: str) -> str: """Convert decimal degrees to DMS string.""" if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value): return "N/A" if coord_type.lower() not in ("lat", "lon"): logger.warning(f"Unknown coord type '{coord_type}'."); return "N/A (Invalid Type)" val_for_suffix = degree_value if coord_type.lower() == "lat": if not -90.0 <= degree_value <= 90.0: logger.warning(f"Lat {degree_value} outside range [-90, 90] for suffix. Clamping.") val_for_suffix = max(-90.0, min(90.0, degree_value)) elif coord_type.lower() == "lon": val_for_suffix = (degree_value + 180) % 360 - 180 if val_for_suffix == -180.0 and degree_value != -180.0: val_for_suffix = 180.0 abs_deg = abs(degree_value) degrees = int(abs_deg) minutes_decimal = (abs_deg - degrees) * 60 minutes = int(minutes_decimal) seconds = (minutes_decimal - minutes) * 60 # Round seconds and carry over if necessary seconds = round(seconds, 2) if seconds >= 60.0: # Can happen after rounding, e.g., 59.996 rounds to 60.00 seconds = 0.0 minutes += 1 if minutes >= 60: minutes = 0 degrees += 1 # Handle degrees reaching 90 or 180 due to rounding, for suffix logic if coord_type.lower() == "lat" and degrees > 90: degrees = 90 elif coord_type.lower() == "lon" and degrees > 180: degrees = 180 suffix = "" if coord_type.lower() == "lat": suffix = "N" if val_for_suffix >= 0 else "S" elif coord_type.lower() == "lon": suffix = "E" if val_for_suffix >= 0 else "W" if val_for_suffix == 0.0: suffix = "" # 0 longitude doesn't need E/W try: # Use constants for symbols dms_string = f"{degrees}{map_constants.DMS_DEGREE_SYMBOL} {minutes}{map_constants.DMS_MINUTE_SYMBOL} {seconds:.2f}{map_constants.DMS_SECOND_SYMBOL}" except AttributeError: logger.warning("map_constants missing DMS symbols, using plain text."); dms_string = f"{degrees} Deg {minutes} Min {seconds:.2f} Sec" except Exception as e: logger.error(f"Error formatting DMS string: {e}. Plain text.", exc_info=False); dms_string = f"{degrees} Deg {minutes} Min {seconds:.2f} Sec" if suffix: dms_string += f" {suffix}" return dms_string def get_combined_geographic_bounds_from_tile_info_list( tile_info_list: List[Dict], ) -> Optional[Tuple[float, float, float, float]]: """Calc min BBox encompassing all tiles in list.""" if not tile_info_list: logger.warning("Tile info list empty for combined bounds.") return None min_lon, min_lat, max_lon, max_lat = 180.0, 90.0, -180.0, -90.0 initialized = False for tile_info in tile_info_list: lat_coord = tile_info.get("latitude_coord") lon_coord = tile_info.get("longitude_coord") if lat_coord is None or lon_coord is None: logger.warning(f"Skipping tile info: missing coords {tile_info}.") continue try: tile_bounds = get_hgt_tile_geographic_bounds(int(lat_coord), int(lon_coord)) if tile_bounds: if not initialized: min_lon, min_lat, max_lon, max_lat = tile_bounds initialized = True else: min_lon = min(min_lon, tile_bounds[0]) min_lat = min(min_lat, tile_bounds[1]) max_lon = max(max_lon, tile_bounds[2]) max_lat = max(max_lat, tile_bounds[3]) else: logger.warning(f"Could not get geo bounds for tile ({lat_coord},{lon_coord}), skipping.") except (ValueError, TypeError) as e: logger.warning(f"Invalid coord type in tile info ({lat_coord},{lon_coord}): {e}. Skipping.", exc_info=False) except Exception as e: logger.warning(f"Error getting bounds for tile ({lat_coord},{lon_coord}): {e}. Skipping.", exc_info=True) if not initialized: logger.warning("No valid tiles in list for combined bounds."); return None if min_lat >= max_lat: logger.warning(f"Calc invalid combined lat range: S={min_lat}, N={max_lat}."); return None # Longitude wrapping in combined bounds from multiple tiles is possible, but harder to represent as simple (W,S,E,N) tuple. # The current min/max calculation assumes no wrapping. If tiles cross antimeridian, this needs refinement. # Given the expected use case (local map views), assuming non-wrapping longitude range is usually fine. return (min_lon, min_lat, max_lon, max_lat) 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] ]: """Calc geo BBox centered at point corresponding to pixel size/zoom.""" if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("'pyproj' required for BBox from pixel size/zoom.") return None if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: logger.error("'mercantile' required for BBox from pixel size/zoom (for mercator conversion).") return None if not (isinstance(center_latitude_deg, (int, float)) and -90.0 <= center_latitude_deg <= 90.0): logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}."); return None if not (isinstance(center_longitude_deg, (int, float)) and -180.0 <= center_longitude_deg <= 180.0): logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}."); return None if not (isinstance(target_pixel_width, int) and target_pixel_width > 0 and isinstance(target_pixel_height, int) and target_pixel_height > 0): logger.error(f"Invalid pixel dims ({target_pixel_width}x{target_pixel_height})."); return None if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): logger.warning(f"Invalid zoom level: {zoom_level}. Clamping to."); zoom_level = max(0, min(zoom_level, 25)) # Clamp zoom level if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): logger.error(f"Invalid tile_pixel_size: {tile_pixel_size}."); return None try: # Convert center geo to mercator pixel at zoom 0 # Origin (0,0) in mercator pixel space at zoom 0 is the top-left corner of the earth map. # X increases east, Y increases south. Total size is 256x256 pixels at zoom 0. # Using the tile_pixel_size gives the size relative to that. origin_mercator_x_at_z0, origin_mercator_y_at_z0 = mercantile.xy(-180.0, 85.05112878) # Top-left corner of web mercator projection center_mercator_x, center_mercator_y = mercantile.xy(center_longitude_deg, center_latitude_deg) # Calculate the pixel coordinates of the center point at the target zoom level # Pixel coordinates at zoom Z are related to mercator coordinates and tile size. # Total earth pixel size at zoom Z is tile_pixel_size * 2^Z total_earth_pixels_z = tile_pixel_size * (2**zoom_level) # Mercator X/Y range from (-PI * R, PI * R) # Total width/height in mercator units is 2 * PI * R (~40075016.686 meters) # Resolution at equator at zoom Z is (2 * PI * R) / (tile_pixel_size * 2^Z) meters/pixel # A simpler way using mercantile x/y: # X pixel = (mercator_x - mercator_min_x) / mercator_total_width * total_earth_pixels_z # Y pixel = (mercator_max_y - mercator_y) / mercator_total_height * total_earth_pixels_z (Y is inverted) # mercantile.xy returns coordinates in "Web Mercator" units (meters), relative to the origin of the projection. # The origin of the projection is at (-180, +85.0511) geographic, corresponding to mercator (-20037508.34, 20037508.34). # The full extent is 2 * 20037508.34 meters in both directions. mercator_extent_meters = 20037508.34 * 2 # Total width/height of the Mercator projection in meters center_pixel_x_z0 = (center_mercator_x - (-mercator_extent_meters/2)) / mercator_extent_meters * tile_pixel_size # Pixel relative to 256x256 tile at z0 center_pixel_y_z0 = ( (mercator_extent_meters/2) - center_mercator_y) / mercator_extent_meters * tile_pixel_size # Pixel relative to 256x256 tile at z0 (inverted Y) # Pixel coordinates at zoom Z are pixel_at_z0 * 2^Z center_pixel_x_z = center_pixel_x_z0 * (2**zoom_level) center_pixel_y_z = center_pixel_y_z0 * (2**zoom_level) # The target BBox pixel center is (target_pixel_width/2, target_pixel_height/2) relative to its top-left corner. # The top-left pixel of the target BBox corresponds to a mercator coordinate. # Let BBox_center_pixel_x, BBox_center_pixel_y be the center pixel of the *rendered* BBox (at the center of the canvas). # The top-left pixel of the BBox is (BBox_center_pixel_x - target_pixel_width/2, BBox_center_pixel_y - target_pixel_height/2) # The bottom-right pixel is (BBox_center_pixel_x + target_pixel_width/2, BBox_center_pixel_y + target_pixel_height/2) # Assuming the stitched image is centered on the geo coordinate center_latitude_deg, center_longitude_deg, # the center of the stitched image is at pixel (img_width/2, img_height/2). # The pixel coordinates relative to the *top-left of the entire stitched map at zoom Z* # that corresponds to the target BBox's top-left corner would be: # ul_pixel_x_z = center_pixel_x_z - target_pixel_width / 2.0 # ul_pixel_y_z = center_pixel_y_z - target_pixel_height / 2.0 # lr_pixel_x_z = center_pixel_x_z + target_pixel_width / 2.0 # lr_pixel_y_z = center_pixel_y_z + target_pixel_height / 2.0 # Convert these pixel coordinates back to mercator, then to geo. # mercator_x = mercator_min_x + (pixel_x_z / total_earth_pixels_z) * mercator_total_width # mercator_y = mercator_max_y - (pixel_y_z / total_earth_pixels_z) * mercator_total_height # Inverted Y ul_pixel_x_z = center_pixel_x_z - target_pixel_width / 2.0 ul_pixel_y_z = center_pixel_y_z - target_pixel_height / 2.0 lr_pixel_x_z = center_pixel_x_z + target_pixel_width / 2.0 lr_pixel_y_z = center_pixel_y_z + target_pixel_height / 2.0 ul_mercator_x = (-mercator_extent_meters/2) + (ul_pixel_x_z / total_earth_pixels_z) * mercator_extent_meters ul_mercator_y = (mercator_extent_meters/2) - (ul_pixel_y_z / total_earth_pixels_z) * mercator_extent_meters # Inverted Y lr_mercator_x = (-mercator_extent_meters/2) + (lr_pixel_x_z / total_earth_pixels_z) * mercator_extent_meters lr_mercator_y = (mercator_extent_meters/2) - (lr_pixel_y_z / total_earth_pixels_z) * mercator_extent_meters # Inverted Y # Convert mercator corners back to geo (lon, lat) ul_lon, ul_lat = mercantile.lnglat(ul_mercator_x, ul_mercator_y) lr_lon, lr_lat = mercantile.lnglat(lr_mercator_x, lr_mercator_y) # The bounding box is (west_lon, south_lat, east_lon, north_lat) west_lon = ul_lon north_lat = ul_lat east_lon = lr_lon south_lat = lr_lat # Ensure latitudes are within valid range for WGS84 north_lat = min(90.0, max(-90.0, north_lat)) south_lat = min(90.0, max(-90.0, south_lat)) # Handle longitude wrapping (e.g., if the canvas width spans the antimeridian) # mercantile.lnglat handles the -180/180 wrap, but the calculated east_lon might be < west_lon. # For consistency, ensure west_lon <= east_lon if not crossing antimeridian. # If it crosses, the standard (W,S,E,N) tuple doesn't represent it well. # Given our assumption for OpenSky, we expect non-wrapping BBoxes. # If lr_lon < ul_lon, it might indicate wrapping, but for a single canvas view, # we usually expect ul_lon < lr_lon unless the canvas is extremely wide and centered on antimeridian. # For this calculation, we just return the calculated ul_lon and lr_lon as west and east. if (abs(west_lon - east_lon) < 1e-9 or abs(south_lat - north_lat) < 1e-9): logger.warning("Calc zero-size geo BBox from pixel size/zoom.") return None if not all(math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat]): logger.error("Calc geo BBox from pixel size/zoom contains non-finite values.") return None return (west_lon, south_lat, east_lon, north_lat) except Exception as e_bbox_calc: logger.exception(f"Error calculating geo BBox from pixel size/zoom: {e_bbox_calc}") return None def calculate_inner_geographic_bbox( outer_bbox_deg: Tuple[float, float, float, float], reduction_distance_km: float, ) -> Optional[ Tuple[float, float, float, float] ]: """Calc inner geo BBox by reducing outer BBox by distance (km). Requires pyproj.""" if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None: logger.error("'pyproj' required for inner BBox.") return None west_lon, south_lat, east_lon, north_lat = outer_bbox_deg reduct_dist_m = reduction_distance_km * 1000.0 if not (isinstance(reduction_distance_km, (int, float)) and reduction_distance_km >= 0): logger.error(f"Invalid reduct_dist_km: {reduction_distance_km}."); return None if not (isinstance(west_lon, (int, float)) and isinstance(south_lat, (int, float)) and isinstance(east_lon, (int, float)) and isinstance(north_lat, (int, float))): logger.warning(f"Invalid coord types for outer_bbox: {outer_bbox_deg}."); return None if not (-90.0 <= south_lat <= north_lat <= 90.0): logger.warning(f"Invalid lat range in outer_bbox: {south_lat}, {north_lat}."); return None if not (-180.0 <= west_lon <= 180.0 and -180.0 <= east_lon <= 180.0): logger.warning(f"Invalid lon range in outer_bbox: {west_lon}, {east_lon}."); return None # Assuming non-wrapping input BBox if reduction_distance_km == 0: return outer_bbox_deg try: geod = pyproj.Geod(ellps="WGS84") outer_size_km = calculate_geographic_bbox_size_km(outer_bbox_deg) if outer_size_km is None: logger.error("Could not calc outer BBox size for inner BBox."); return None outer_width_km, outer_height_km = outer_size_km inner_width_km = outer_width_km - (2 * reduction_distance_km) inner_height_km = outer_height_km - (2 * reduction_distance_km) if (inner_width_km <= 1e-9 or inner_height_km <= 1e-9): logger.warning(f"Reduct dist {reduction_distance_km}km too large. Inner BBox size {inner_width_km:.2f}x{inner_height_km:.2f}km is zero or negative.") return None center_lat = (south_lat + north_lat) / 2.0 center_lon = (west_lon + east_lon) / 2.0 if west_lon > east_lon: center_lon = (west_lon + east_lon + 360) / 2.0 if center_lon > 180: center_lon -= 360 center_lat_clamped = max(-89.9, min(89.9, center_lat)) center_lon_clamped = max(-179.9, min(179.9, center_lon)) # Clamp center_lon for fwd if needed half_inner_width_m = inner_width_km * 500.0 half_inner_height_m = inner_height_km * 500.0 # Calculate inner BBox corners by moving from the center _, inner_north_lat, _ = geod.fwd(center_lon_clamped, center_lat_clamped, 0.0, half_inner_height_m) _, inner_south_lat, _ = geod.fwd(center_lon_clamped, center_lat_clamped, 180.0, half_inner_height_m) inner_east_lon, _, _ = geod.fwd(center_lon_clamped, center_lat_clamped, 90.0, half_inner_width_m) inner_west_lon, _, _ = geod.fwd(center_lon_clamped, center_lat_clamped, 270.0, half_inner_width_m) inner_north_lat = min(90.0, max(-90.0, inner_north_lat)); inner_south_lat = min(90.0, max(-90.0, inner_south_lat)) if (abs(inner_west_lon - inner_east_lon) < 1e-9 or abs(inner_south_lat - inner_north_lat) < 1e-9): logger.warning("Calc zero-size inner geo BBox.") return None if not all(math.isfinite(coord) for coord in [inner_west_lon, inner_south_lat, inner_east_lon, inner_north_lat]): logger.error("Calc inner geo BBox contains non-finite values.") return None return (inner_west_lon, inner_south_lat, inner_east_lon, inner_north_lat) except Exception as e_inner_bbox_calc: logger.exception(f"Error calculating inner geo BBox: {e_inner_bbox_calc}") 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]], # (width, height) ) -> Tuple[Optional[float], Optional[float]]: """ Convert canvas pixel (x, y) to geo (lon, lat). Requires mercantile. Uses current map geo bounds and pixel shape for context. """ if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None: logger.error("_pixel_to_geo: Mercantile missing.") return None, None if current_map_geo_bounds is None: logger.warning("_pixel_to_geo: Map geo bounds missing.") return None, None if current_stitched_map_pixel_shape is None: logger.warning("_pixel_to_geo: Map pixel shape missing.") return None img_w, img_h = current_stitched_map_pixel_shape if img_w <= 0 or img_h <= 0: logger.warning(f"_pixel_to_geo: Map pixel dims invalid ({img_w}x{img_h}).") return None, None map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds try: map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) if total_map_width_merc <= 1e-9 or total_map_height_merc <= 1e-9: logger.warning(f"_pixel_to_geo: Map mercator dims zero/near-zero ({total_map_width_merc:.2e}x{total_map_height_merc:.2e}).") return None, None # Calculate the pixel coordinates relative to the top-left of the full mercator projection at the current zoom. # Need the zoom level to know the total pixel size of the earth at that zoom. # This function doesn't receive the zoom level directly, but it's used by MapCanvasManager which has it. # However, the conversion from mercator to pixel relative to the *stitched image* only needs the stitched image geo bounds and pixel size. # Convert pixel position (canvas_x, canvas_y) relative to stitched image to mercator coordinate. # relative_x_on_map_pixels is 0 at the left edge of the stitched image, 1 at the right edge. relative_x_on_map_pixels = canvas_x / img_w # relative_y_on_map_pixels is 0 at the top edge, 1 at the bottom. relative_y_on_map_pixels = canvas_y / img_h # Convert relative pixel position to relative mercator position # X: 0.0 at map_west_lon (ul_merc_x), 1.0 at map_east_lon (lr_merc_x) # Y: 0.0 at map_north_lat (ul_merc_y), 1.0 at map_south_lat (lr_merc_y) target_merc_x = map_ul_merc_x + (relative_x_on_map_pixels * total_map_width_merc) target_merc_y = map_ul_merc_y - (relative_y_on_map_pixels * total_map_height_merc) # Y is inverted relative to map_ul_merc_y # Convert mercator back to geo (lon, lat) clicked_lon, clicked_lat = mercantile.lnglat(target_merc_x, target_merc_y) # Clamp latitude to Mercator limits for safety MAX_MERCATOR_LATITUDE = 85.05112878 clicked_lat = max(-MAX_MERCATOR_LATITUDE, min(MAX_MERCATOR_LATITUDE, clicked_lat)) return clicked_lon, clicked_lat except Exception as e_pixel_to_geo: logger.error( f"Error converting pixel ({canvas_x}, {canvas_y}) to geo. Error: {e_pixel_to_geo}", exc_info=True, ) return None, None