"""Web Mercator projection helpers.""" from __future__ import annotations import math from .exceptions import ProjectionError from .types import LatLon, Point WEB_MERCATOR_MAX_LAT = 85.05112878 DEFAULT_TILE_SIZE = 256 def clamp_latitude(lat: float) -> float: """Clamp latitude to the Web Mercator representable range.""" return min(max(lat, -WEB_MERCATOR_MAX_LAT), WEB_MERCATOR_MAX_LAT) def map_size(zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> int: """Return square pixel size of the full world at a zoom level.""" if zoom < 0: raise ProjectionError("zoom must be >= 0") if tile_size <= 0: raise ProjectionError("tile_size must be > 0") return tile_size * (2**zoom) def latlon_to_world(lat: float, lon: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> Point: """Project latitude/longitude to world pixel coordinates.""" size = map_size(zoom, tile_size) lat = clamp_latitude(lat) lon = ((lon + 180.0) % 360.0) - 180.0 sin_lat = math.sin(math.radians(lat)) x = (lon + 180.0) / 360.0 * size y = (0.5 - math.log((1.0 + sin_lat) / (1.0 - sin_lat)) / (4.0 * math.pi)) * size return (x, y) def world_to_latlon(x: float, y: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> LatLon: """Unproject world pixel coordinates to latitude/longitude.""" size = map_size(zoom, tile_size) lon = x / size * 360.0 - 180.0 n = math.pi - 2.0 * math.pi * y / size lat = math.degrees(math.atan(math.sinh(n))) return (lat, lon) def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int, int]: """Return the XYZ tile coordinate containing a latitude/longitude point.""" x, y = latlon_to_world(lat, lon, zoom) scale = 2**zoom tile_x = min(max(int(x // DEFAULT_TILE_SIZE), 0), scale - 1) tile_y = min(max(int(y // DEFAULT_TILE_SIZE), 0), scale - 1) return (tile_x, tile_y, zoom) def world_to_screen( world_x: float, world_y: float, *, center: LatLon, zoom: int, width: int, height: int, tile_size: int = DEFAULT_TILE_SIZE, ) -> Point: """Convert world pixels to screen pixels for a centered viewport.""" center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0) def screen_to_world( screen_x: float, screen_y: float, *, center: LatLon, zoom: int, width: int, height: int, tile_size: int = DEFAULT_TILE_SIZE, ) -> Point: """Convert screen pixels to world pixels for a centered viewport.""" center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) return (screen_x + center_x - width / 2.0, screen_y + center_y - height / 2.0)