# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
"""
Web Mercator tile arithmetic helpers.
This module contains :class:`TileUtils`, a collection of static helpers for
converting between WGS84 coordinates, XYZ tile indices, and Mapbox Vector
Tile (MVT) local coordinates, plus a small timestamp utility used when
parsing tile metadata.
Example:
>>> from rapidtools.data_sources import TileUtils
>>> x, y = TileUtils.latlon_to_tile(34.05, -118.25, 15)
>>> int(x), int(y)
(5620, 13084)
"""
import math
from datetime import UTC, datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from rapidtools.core.bounding_box import BoundingBox
MAX_MERCATOR_LAT = 85.05112878
[docs]
class TileUtils:
"""
Utility methods for Web Mercator/Mapbox Vector Tile operations.
This class provides static helpers for:
- Converting between WGS84 (lat/lon) and XYZ tile coordinates
- Converting Mapbox Vector Tile (MVT) local coordinates to WGS84
- Determining zoom levels and tile coverage for bounding boxes
- Converting millisecond timestamps to UTC dates
All geographic coordinates are assumed to be:
- Latitude in degrees, in the range [-85.05112878, 85.05112878]
- Longitude in degrees, in the range [-180, 180]
Example:
>>> from rapidtools.core import BoundingBox
>>> from rapidtools.data_sources import TileUtils
>>>
>>> bbox = BoundingBox(-118.251, 34.050, -118.250, 34.051)
>>> tiles = TileUtils.bbox_to_mapbox_tiles(bbox, zoom=16)
>>> all(z == 16 for _, _, z in tiles)
True
"""
[docs]
@staticmethod
def latlon_to_tile(lat: float, lon: float, z: int) -> tuple[float, float]:
"""
Convert latitude/longitude to fractional XYZ tile coordinates.
Uses the Web Mercator tiling scheme where: at zoom level ``z`` there
are ``2^z`` tiles in both X and Y.
Args:
lat: Latitude in degrees.
lon: Longitude in degrees.
z: Zoom level (e.g., 0-22).
Returns:
A tuple ``(xtile, ytile)`` of fractional tile coordinates at
zoom ``z``. The integer parts ``floor(xtile)`` and
``floor(ytile)`` are the tile indices; the fractional parts
represent the position within that tile.
Example:
>>> TileUtils.latlon_to_tile(0.0, 0.0, 1)
(1.0, 1.0)
"""
# Beyond the Mercator limit the projection diverges; clamp so the
# result stays inside [0, n]:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
lat_rad = math.radians(lat)
n = 2.0**z
xtile = (lon + 180.0) / 360.0 * n
ytile = (1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n
return xtile, ytile
[docs]
@staticmethod
def mvt_to_wgs84(
shape_x: int,
shape_y: int,
tile_x: int,
tile_y: int,
tile_z: int,
extent: int = 4096,
) -> tuple[float, float]:
"""
Convert MVT local tile coordinates to WGS84 (longitude, latitude).
The local coordinates ``shape_x`` and ``shape_y`` are integers in the
coordinate space of a single Mapbox Vector Tile (commonly
``0..extent``). The enclosing tile is specified by global XYZ tile
indices.
Args:
shape_x: X coordinate in the MVT local coordinate space
(0 to ``extent``).
shape_y: Y coordinate in the MVT local coordinate space
(0 to ``extent``).
tile_x: Global tile X index at zoom ``tile_z``.
tile_y: Global tile Y index at zoom ``tile_z``.
tile_z: Zoom level of the tile.
extent: Resolution of the local tile coordinate grid. Defaults
to 4096 (typical for Mapbox Vector Tiles).
Returns:
A tuple ``(lon, lat)`` where:
- ``lon`` is longitude in degrees (WGS84).
- ``lat`` is latitude in degrees (WGS84).
Example:
>>> lon, lat = TileUtils.mvt_to_wgs84(0, 0, 0, 0, 0)
>>> lon, round(lat, 6)
(-180.0, 85.051129)
"""
n = 2.0**tile_z
# Calculate global X/Y in Tile Space:
x_val = tile_x + (shape_x / extent)
y_val = tile_y + (shape_y / extent)
# Calculate the longitude:
lon = (x_val / n) * 360.0 - 180.0
# Latitude involves the inverse Mercator projection:
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y_val / n)))
lat = math.degrees(lat_rad)
return lon, lat
[docs]
@staticmethod
def ms_to_date_utc(timestamp_ms: int) -> str:
"""
Convert a millisecond UNIX timestamp to a UTC date string.
The input timestamp is interpreted as milliseconds since the Unix epoch
(1970-01-01T00:00:00Z). The function converts it to a UTC date and
returns an ISO 8601 formatted string (YYYY-MM-DD).
Args:
timestamp_ms: Timestamp in milliseconds since Unix epoch (UTC).
Returns:
A Unicode string representing the UTC date in ISO format
(YYYY-MM-DD).
Example:
>>> TileUtils.ms_to_date_utc(0)
'1970-01-01'
>>> TileUtils.ms_to_date_utc(1_700_000_000_000)
'2023-11-14'
"""
seconds = timestamp_ms / 1000.0
dt_object = datetime.fromtimestamp(seconds, tz=UTC)
return dt_object.date().isoformat()
[docs]
@staticmethod
def get_enclosing_zoom(bbox: 'BoundingBox') -> int:
"""
Find the highest zoom where the bounding box fits inside one tile.
This method searches zoom levels from 22 down to 14 and returns the
maximum zoom level at which the entire bounding box is contained within
a single XYZ tile. If the bounding box spans multiple tiles at all
zooms in that range, 14 is returned.
The bounding box is expected to provide Shapely-like bounds in
the order ``(min_lon, min_lat, max_lon, max_lat)``.
Args:
bbox: Bounding box object with a ``shapely`` geometry exposing a
``.bounds`` attribute.
Returns:
The highest integer zoom level in the range [14, 22] at which
the bounding box fits into a single tile. Returns 14 if no higher
zoom level satisfies this condition.
Example:
>>> from rapidtools.core import BoundingBox
>>> # A very large box never fits in one tile above zoom 14:
>>> TileUtils.get_enclosing_zoom(BoundingBox(-120, 30, -110, 40))
14
"""
min_lon, min_lat, max_lon, max_lat = bbox.bounds
# Iterate from high zoom (22) down to 14 (inclusive)
for z in range(22, 13, -1):
# Calculate tile coords for Top-Left (North-West):
x1, y1 = TileUtils.latlon_to_tile(max_lat, min_lon, z)
# Calculate tile coords for Bottom-Right (South-East):
x2, y2 = TileUtils.latlon_to_tile(min_lat, max_lon, z)
# Check if the integer parts are the same, i.e., they are in the
# same tile:
if int(x1) == int(x2) and int(y1) == int(y2):
return z
# If it crosses a tile boundary even at zoom 14, return the lowest
# allowed zoom.
return 14
[docs]
@staticmethod
def bbox_to_mapbox_tiles(
bbox: 'BoundingBox',
zoom: int | None = None,
) -> list[tuple[int, int, int]]:
"""
Compute XYZ tiles that cover a bounding box.
The bounding box is given in WGS84 coordinates via the
``bbox.bounds`` attribute. Tiles are computed using the
standard Web Mercator XYZ scheme.
If ``zoom`` is not provided, the highest zoom at which the entire
bounding box fits into a single tile (as returned by
:meth:`get_enclosing_zoom`) is used. In that case the result is often
a single tile.
Args:
bbox: Bounding box object with a ``shapely`` geometry exposing a
``.bounds`` attribute in the order
``(min_lon, min_lat, max_lon, max_lat)``.
zoom: Zoom level at which to compute coverage. If ``None``, the
zoom level is determined by :meth:`get_enclosing_zoom`.
Returns:
A list of tile coordinates ``(x, y, z)`` covering the bounding box,
including all integer tiles from ``min_x..max_x`` and
``min_y..max_y`` inclusive.
Example:
>>> from rapidtools.core import BoundingBox
>>> TileUtils.bbox_to_mapbox_tiles(BoundingBox(-1, -1, 1, 1), zoom=1)
[(0, 0, 1), (0, 1, 1), (1, 0, 1), (1, 1, 1)]
"""
# Extract the WGS84 bounds from the Shapely geometry:
# min/max longitude (x), min/max latitude (y)
min_lon, min_lat, max_lon, max_lat = bbox.bounds
# If no zoom is provided, pick the highest zoom at which the entire
# bbox fits into a single tile. This usually yields 1 tile:
if zoom is None:
zoom = TileUtils.get_enclosing_zoom(bbox)
# Convert the north-west (max_lat, min_lon) and south-east
# (min_lat, max_lon) corners of the bbox into fractional tile
# coordinates at the chosen zoom:
min_x_float, min_y_float = TileUtils.latlon_to_tile(max_lat, min_lon, zoom)
max_x_float, max_y_float = TileUtils.latlon_to_tile(min_lat, max_lon, zoom)
# Take the integer parts to get the tile index range that covers the
# bbox. A bbox edge on lon=180 or at the Mercator limit gives the
# fractional coordinate 2^zoom, which belongs to the last tile:
last_index = (1 << zoom) - 1
min_x = min(max(int(min_x_float), 0), last_index)
min_y = min(max(int(min_y_float), 0), last_index)
max_x = min(max(int(max_x_float), 0), last_index)
max_y = min(max(int(max_y_float), 0), last_index)
# Build the full list of tiles in the inclusive [min_x..max_x] x
# [min_y..max_y] range:
tiles: list[tuple[int, int, int]] = []
for x in range(min_x, max_x + 1):
for y in range(min_y, max_y + 1):
tiles.append((x, y, zoom))
return tiles