Source code for pysepal.mapping.bounds

"""Auxiliary functions for computing map bounds and zoom levels."""

import math

from pyproj import Transformer


[docs] def compute_center(bounds): """Given [[south, west], [north, east]], return (lat, lon) center.""" (south, west), (north, east) = bounds return ((south + north) / 2, (west + east) / 2)
_MERCATOR_MAX_LAT = 85.05112878 def _mercator_y_px(lat, world_px): """Return Web Mercator Y pixel coordinate at the given world pixel size.""" lat = max(-_MERCATOR_MAX_LAT, min(_MERCATOR_MAX_LAT, lat)) lat_rad = math.radians(lat) return world_px * (0.5 - math.log(math.tan(math.pi / 4 + lat_rad / 2)) / (2 * math.pi))
[docs] def viewport_pixels_from_map(map_widget): """Derive the map viewport (width, height) in pixels from synced bounds+zoom. Longitude is linear in Web Mercator pixels (360° == ``256 * 2**zoom``), so the east-west bounds span gives width directly. Latitude is non-linear (Mercator stretches toward the poles), so height is computed via the Mercator Y formula. Returns ``(None, None)`` if bounds/zoom are not yet available. """ bounds = getattr(map_widget, "bounds", None) zoom = getattr(map_widget, "zoom", None) if not bounds or zoom is None: return None, None try: (south, west), (north, east) = bounds except (TypeError, ValueError): return None, None world_px = 256 * (2**zoom) span_lon = (east - west) % 360 or 360 width = span_lon * world_px / 360 height = abs(_mercator_y_px(north, world_px) - _mercator_y_px(south, world_px)) return (width if width > 0 else None, height if height > 0 else None)
[docs] def viewport_width_from_map(map_widget): """Back-compat wrapper returning only the viewport width in pixels.""" return viewport_pixels_from_map(map_widget)[0]
[docs] def compute_zoom_for_bounds( bounds, map_width_px=1024, map_height_px=None, min_zoom=None, max_zoom=None ): """Calculate the Web Mercator zoom that fits `bounds` into the viewport. If ``map_height_px`` is provided, the zoom is limited by whichever axis runs out of pixels first (so tall bounds in a wide viewport, or vice versa, still fit fully). If omitted, behaviour matches the previous width-only calculation. """ transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) (south, west), (north, east) = bounds min_x, min_y = transformer.transform(west, south) max_x, max_y = transformer.transform(east, north) # Determine span in meters span_x = abs(max_x - min_x) span_y = abs(max_y - min_y) if span_x == 0 and span_y == 0: # Return the maximum zoom level for a point zoom = max_zoom if max_zoom is not None else 18 return zoom # meters per pixel needed to fit each axis; the larger one is the limit res_x = span_x / map_width_px if map_width_px > 0 else float("inf") if map_height_px and map_height_px > 0: res_y = span_y / map_height_px else: # Fallback: treat viewport as square (legacy behaviour) res_y = span_y / map_width_px if map_width_px > 0 else float("inf") resolution = max(res_x, res_y) # Derive zoom: WORLD_SIZE / (tile_size * 2**zoom) = resolution WORLD_SIZE = 2 * math.pi * 6_378_137 tile_size = 256 zoom_float = math.log2(WORLD_SIZE / (tile_size * resolution)) zoom = int(math.floor(zoom_float)) # Clamp to allowed range if min_zoom is not None: zoom = max(min_zoom, zoom) if max_zoom is not None: zoom = min(max_zoom, zoom) return zoom