"""Web mercator XYZ tile utilities""" from collections import namedtuple from functools import reduce import math import sys import warnings import operator if sys.version_info < (3,): warnings.warn( "Python versions < 3 will not be supported by mercantile 2.0.", UserWarning, ) from collections import Sequence def lru_cache(maxsize=None): """Does nothing. We do not cache for Python < 3.""" def fake_decorator(func): return func return fake_decorator else: from collections.abc import Sequence from functools import lru_cache __version__ = "1.2.1" __all__ = [ "Bbox", "LngLat", "LngLatBbox", "Tile", "bounding_tile", "bounds", "children", "feature", "lnglat", "neighbors", "parent", "quadkey", "quadkey_to_tile", "simplify", "tile", "tiles", "ul", "xy_bounds", "minmax", ] R2D = 180 / math.pi RE = 6378137.0 CE = 2 * math.pi * RE EPSILON = 1e-14 LL_EPSILON = 1e-11 class Tile(namedtuple("Tile", ["x", "y", "z"])): """An XYZ web mercator tile Attributes ---------- x, y, z : int x and y indexes of the tile and zoom level z. """ def __new__(cls, x, y, z): """A new instance""" lo, hi = minmax(z) if not lo <= x <= hi or not lo <= y <= hi: warnings.warn( "Mercantile 2.0 will require tile x and y to be within the range (0, 2 ** zoom)", FutureWarning, ) return tuple.__new__(cls, [x, y, z]) LngLat = namedtuple("LngLat", ["lng", "lat"]) """A longitude and latitude pair Attributes ---------- lng, lat : float Longitude and latitude in decimal degrees east or north. """ LngLatBbox = namedtuple("LngLatBbox", ["west", "south", "east", "north"]) """A geographic bounding box Attributes ---------- west, south, east, north : float Bounding values in decimal degrees. """ Bbox = namedtuple("Bbox", ["left", "bottom", "right", "top"]) """A web mercator bounding box Attributes ---------- left, bottom, right, top : float Bounding values in meters. """ class MercantileError(Exception): """Base exception""" class InvalidLatitudeError(MercantileError): """Raised when math errors occur beyond ~85 degrees N or S""" class InvalidZoomError(MercantileError): """Raised when a zoom level is invalid""" class ParentTileError(MercantileError): """Raised when a parent tile cannot be determined""" class QuadKeyError(MercantileError): """Raised when errors occur in computing or parsing quad keys""" class TileArgParsingError(MercantileError): """Raised when errors occur in parsing a function's tile arg(s)""" class TileError(MercantileError): """Raised when a tile can't be determined""" def _parse_tile_arg(*args): """parse the *tile arg of module functions Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. Returns ------- Tile Raises ------ TileArgParsingError """ if len(args) == 1: args = args[0] if len(args) == 3: return Tile(*args) else: raise TileArgParsingError( "the tile argument may have 1 or 3 values. Note that zoom is a keyword-only argument" ) def ul(*tile): """Returns the upper left longitude and latitude of a tile Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. Returns ------- LngLat Examples -------- >>> ul(Tile(x=0, y=0, z=1)) LngLat(lng=-180.0, lat=85.0511287798066) >>> mercantile.ul(1, 1, 1) LngLat(lng=0.0, lat=0.0) """ tile = _parse_tile_arg(*tile) xtile, ytile, zoom = tile Z2 = math.pow(2, zoom) lon_deg = xtile / Z2 * 360.0 - 180.0 lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / Z2))) lat_deg = math.degrees(lat_rad) return LngLat(lon_deg, lat_deg) def bounds(*tile): """Returns the bounding box of a tile Parameters ---------- tile : Tile or tuple May be be either an instance of Tile or 3 ints (X, Y, Z). Returns ------- LngLatBbox """ tile = _parse_tile_arg(*tile) xtile, ytile, zoom = tile Z2 = math.pow(2, zoom) ul_lon_deg = xtile / Z2 * 360.0 - 180.0 ul_lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / Z2))) ul_lat_deg = math.degrees(ul_lat_rad) lr_lon_deg = (xtile + 1) / Z2 * 360.0 - 180.0 lr_lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * (ytile + 1) / Z2))) lr_lat_deg = math.degrees(lr_lat_rad) return LngLatBbox(ul_lon_deg, lr_lat_deg, lr_lon_deg, ul_lat_deg) def truncate_lnglat(lng, lat): if lng > 180.0: lng = 180.0 elif lng < -180.0: lng = -180.0 if lat > 90.0: lat = 90.0 elif lat < -90.0: lat = -90.0 return lng, lat def xy(lng, lat, truncate=False): """Convert longitude and latitude to web mercator x, y Parameters ---------- lng, lat : float Longitude and latitude in decimal degrees. truncate : bool, optional Whether to truncate or clip inputs to web mercator limits. Returns ------- x, y : float y will be inf at the North Pole (lat >= 90) and -inf at the South Pole (lat <= -90). """ if truncate: lng, lat = truncate_lnglat(lng, lat) x = RE * math.radians(lng) if lat <= -90: y = float("-inf") elif lat >= 90: y = float("inf") else: y = RE * math.log(math.tan((math.pi * 0.25) + (0.5 * math.radians(lat)))) return x, y def lnglat(x, y, truncate=False): """Convert web mercator x, y to longitude and latitude Parameters ---------- x, y : float web mercator coordinates in meters. truncate : bool, optional Whether to truncate or clip inputs to web mercator limits. Returns ------- LngLat """ lng, lat = ( x * R2D / RE, ((math.pi * 0.5) - 2.0 * math.atan(math.exp(-y / RE))) * R2D, ) if truncate: lng, lat = truncate_lnglat(lng, lat) return LngLat(lng, lat) def neighbors(*tile, **kwargs): """The neighbors of a tile The neighbors function makes no guarantees regarding neighbor tile ordering. The neighbors function returns up to eight neighboring tiles, where tiles will be omitted when they are not valid e.g. Tile(-1, -1, z). Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. Returns ------- list Examples -------- >>> neighbors(Tile(486, 332, 10)) [Tile(x=485, y=331, z=10), Tile(x=485, y=332, z=10), Tile(x=485, y=333, z=10), Tile(x=486, y=331, z=10), Tile(x=486, y=333, z=10), Tile(x=487, y=331, z=10), Tile(x=487, y=332, z=10), Tile(x=487, y=333, z=10)] """ xtile, ytile, ztile = _parse_tile_arg(*tile) tiles = [] lo, hi = minmax(ztile) for i in [-1, 0, 1]: for j in [-1, 0, 1]: if i == 0 and j == 0: continue elif xtile + i < 0 or ytile + j < 0: continue elif xtile + i > hi or ytile + j > hi: continue tiles.append(Tile(x=xtile + i, y=ytile + j, z=ztile)) # Make sure to not generate invalid tiles for valid input # https://github.com/mapbox/mercantile/issues/122 def valid(tile): validx = 0 <= tile.x <= 2 ** tile.z - 1 validy = 0 <= tile.y <= 2 ** tile.z - 1 validz = 0 <= tile.z return validx and validy and validz tiles = [t for t in tiles if valid(t)] return tiles def xy_bounds(*tile): """Get the web mercator bounding box of a tile Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. Returns ------- Bbox Notes ----- Epsilon is subtracted from the right limit and added to the bottom limit. """ tile = _parse_tile_arg(*tile) xtile, ytile, zoom = tile tile_size = CE / math.pow(2, zoom) left = xtile * tile_size - CE / 2 right = left + tile_size top = CE / 2 - ytile * tile_size bottom = top - tile_size return Bbox(left, bottom, right, top) def _xy(lng, lat, truncate=False): if truncate: lng, lat = truncate_lnglat(lng, lat) x = lng / 360.0 + 0.5 sinlat = math.sin(math.radians(lat)) try: y = 0.5 - 0.25 * math.log((1.0 + sinlat) / (1.0 - sinlat)) / math.pi except (ValueError, ZeroDivisionError): raise InvalidLatitudeError("Y can not be computed: lat={!r}".format(lat)) else: return x, y def tile(lng, lat, zoom, truncate=False): """Get the tile containing a longitude and latitude Parameters ---------- lng, lat : float A longitude and latitude pair in decimal degrees. zoom : int The web mercator zoom level. truncate : bool, optional Whether or not to truncate inputs to limits of web mercator. Returns ------- Tile """ x, y = _xy(lng, lat, truncate=truncate) Z2 = math.pow(2, zoom) if x <= 0: xtile = 0 elif x >= 1: xtile = int(Z2 - 1) else: # To address loss of precision in round-tripping between tile # and lng/lat, points within EPSILON of the right side of a tile # are counted in the next tile over. xtile = int(math.floor((x + EPSILON) * Z2)) if y <= 0: ytile = 0 elif y >= 1: ytile = int(Z2 - 1) else: ytile = int(math.floor((y + EPSILON) * Z2)) return Tile(xtile, ytile, zoom) def quadkey(*tile): """Get the quadkey of a tile Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. Returns ------- str """ tile = _parse_tile_arg(*tile) xtile, ytile, zoom = tile qk = [] for z in range(zoom, 0, -1): digit = 0 mask = 1 << (z - 1) if xtile & mask: digit += 1 if ytile & mask: digit += 2 qk.append(str(digit)) return "".join(qk) def quadkey_to_tile(qk): """Get the tile corresponding to a quadkey Parameters ---------- qk : str A quadkey string. Returns ------- Tile """ if len(qk) == 0: return Tile(0, 0, 0) xtile, ytile = 0, 0 for i, digit in enumerate(reversed(qk)): mask = 1 << i if digit == "1": xtile = xtile | mask elif digit == "2": ytile = ytile | mask elif digit == "3": xtile = xtile | mask ytile = ytile | mask elif digit != "0": warnings.warn( "QuadKeyError will not derive from ValueError in mercantile 2.0.", DeprecationWarning, ) raise QuadKeyError("Unexpected quadkey digit: %r", digit) return Tile(xtile, ytile, i + 1) def tiles(west, south, east, north, zooms, truncate=False): """Get the tiles overlapped by a geographic bounding box Parameters ---------- west, south, east, north : sequence of float Bounding values in decimal degrees. zooms : int or sequence of int One or more zoom levels. truncate : bool, optional Whether or not to truncate inputs to web mercator limits. Yields ------ Tile Notes ----- A small epsilon is used on the south and east parameters so that this function yields exactly one tile when given the bounds of that same tile. """ if truncate: west, south = truncate_lnglat(west, south) east, north = truncate_lnglat(east, north) if west > east: bbox_west = (-180.0, south, east, north) bbox_east = (west, south, 180.0, north) bboxes = [bbox_west, bbox_east] else: bboxes = [(west, south, east, north)] for w, s, e, n in bboxes: # Clamp bounding values. w = max(-180.0, w) s = max(-85.051129, s) e = min(180.0, e) n = min(85.051129, n) if not isinstance(zooms, Sequence): zooms = [zooms] for z in zooms: ul_tile = tile(w, n, z) lr_tile = tile(e - LL_EPSILON, s + LL_EPSILON, z) for i in range(ul_tile.x, lr_tile.x + 1): for j in range(ul_tile.y, lr_tile.y + 1): yield Tile(i, j, z) def parent(*tile, **kwargs): """Get the parent of a tile The parent is the tile of one zoom level lower that contains the given "child" tile. Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. zoom : int, optional Determines the *zoom* level of the returned parent tile. This defaults to one lower than the tile (the immediate parent). Returns ------- Tile Examples -------- >>> parent(Tile(0, 0, 2)) Tile(x=0, y=0, z=1) >>> parent(Tile(0, 0, 2), zoom=0) Tile(x=0, y=0, z=0) """ tile = _parse_tile_arg(*tile) x, y, z = tile if z == 0: return None # zoom is a keyword-only argument. zoom = kwargs.get("zoom", None) if zoom is not None and (z <= zoom or zoom != int(zoom)): raise InvalidZoomError( "zoom must be an integer and less than that of the input tile" ) if x != int(x) or y != int(y) or z != int(z): raise ParentTileError("the parent of a non-integer tile is undefined") target_zoom = z - 1 if zoom is None else zoom # Algorithm heavily inspired by https://github.com/mapbox/tilebelt. return_tile = tile while return_tile[2] > target_zoom: xtile, ytile, ztile = return_tile if xtile % 2 == 0 and ytile % 2 == 0: return_tile = Tile(xtile // 2, ytile // 2, ztile - 1) elif xtile % 2 == 0: return_tile = Tile(xtile // 2, (ytile - 1) // 2, ztile - 1) elif not xtile % 2 == 0 and ytile % 2 == 0: return_tile = Tile((xtile - 1) // 2, ytile // 2, ztile - 1) else: return_tile = Tile((xtile - 1) // 2, (ytile - 1) // 2, ztile - 1) return return_tile def children(*tile, **kwargs): """Get the children of a tile The children are ordered: top-left, top-right, bottom-right, bottom-left. Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. zoom : int, optional Returns all children at zoom *zoom*, in depth-first clockwise winding order. If unspecified, returns the immediate (i.e. zoom + 1) children of the tile. Returns ------- list Raises ------ InvalidZoomError If the zoom level is not an integer greater than the zoom level of the input tile. Examples -------- >>> children(Tile(0, 0, 0)) [Tile(x=0, y=0, z=1), Tile(x=0, y=1, z=1), Tile(x=1, y=0, z=1), Tile(x=1, y=1, z=1)] >>> children(Tile(0, 0, 0), zoom=2) [Tile(x=0, y=0, z=2), Tile(x=0, y=1, z=2), Tile(x=0, y=2, z=2), Tile(x=0, y=3, z=2), ...] """ tile = _parse_tile_arg(*tile) # zoom is a keyword-only argument. zoom = kwargs.get("zoom", None) xtile, ytile, ztile = tile if zoom is not None and (ztile > zoom or zoom != int(zoom)): raise InvalidZoomError( "zoom must be an integer and greater than that of the input tile" ) target_zoom = zoom if zoom is not None else ztile + 1 tiles = [tile] while tiles[0][2] < target_zoom: xtile, ytile, ztile = tiles.pop(0) tiles += [ Tile(xtile * 2, ytile * 2, ztile + 1), Tile(xtile * 2 + 1, ytile * 2, ztile + 1), Tile(xtile * 2 + 1, ytile * 2 + 1, ztile + 1), Tile(xtile * 2, ytile * 2 + 1, ztile + 1), ] return tiles def simplify(tiles): """Reduces the size of the tileset as much as possible by merging leaves into parents. Parameters ---------- tiles : Sequence of tiles to merge. Returns ------- list """ def merge(merge_set): """Checks to see if there are 4 tiles in merge_set which can be merged. If there are, this merges them. This returns a list of tiles, as well as a boolean indicating if any were merged. By repeatedly applying merge, a tileset can be simplified. """ upwards_merge = {} for tile in merge_set: tile_parent = parent(tile) if tile_parent not in upwards_merge: upwards_merge[tile_parent] = set() upwards_merge[tile_parent] |= {tile} current_tileset = [] changed = False for supertile, children in upwards_merge.items(): if len(children) == 4: current_tileset += [supertile] changed = True else: current_tileset += list(children) return current_tileset, changed # Check to see if a tile and its parent both already exist. # Ensure that tiles are sorted by zoom so parents are encountered first. # If so, discard the child (it's covered in the parent) root_set = set() for tile in sorted(tiles, key=operator.itemgetter(2)): x, y, z = tile is_new_tile = True for supertile in (parent(tile, zoom=i) for i in range(z)): if supertile in root_set: is_new_tile = False continue if is_new_tile: root_set |= {tile} # Repeatedly run merge until no further simplification is possible. is_merging = True while is_merging: root_set, is_merging = merge(root_set) return root_set def rshift(val, n): return (val % 0x100000000) >> n def bounding_tile(*bbox, **kwds): """Get the smallest tile containing a geographic bounding box NB: when the bbox spans lines of lng 0 or lat 0, the bounding tile will be Tile(x=0, y=0, z=0). Parameters ---------- bbox : sequence of float west, south, east, north bounding values in decimal degrees. Returns ------- Tile """ if len(bbox) == 2: bbox += bbox w, s, e, n = bbox truncate = bool(kwds.get("truncate")) if truncate: w, s = truncate_lnglat(w, s) e, n = truncate_lnglat(e, n) e = e - LL_EPSILON s = s + LL_EPSILON try: tmin = tile(w, n, 32) tmax = tile(e, s, 32) except InvalidLatitudeError: return Tile(0, 0, 0) cell = tmin[:2] + tmax[:2] z = _getBboxZoom(*cell) if z == 0: return Tile(0, 0, 0) x = rshift(cell[0], (32 - z)) y = rshift(cell[1], (32 - z)) return Tile(x, y, z) def _getBboxZoom(*bbox): MAX_ZOOM = 28 for z in range(0, MAX_ZOOM): mask = 1 << (32 - (z + 1)) if (bbox[0] & mask) != (bbox[2] & mask) or (bbox[1] & mask) != (bbox[3] & mask): return z return MAX_ZOOM def feature( tile, fid=None, props=None, projected="geographic", buffer=None, precision=None ): """Get the GeoJSON feature corresponding to a tile Parameters ---------- tile : Tile or sequence of int May be be either an instance of Tile or 3 ints, X, Y, Z. fid : str, optional A feature id. props : dict, optional Optional extra feature properties. projected : str, optional Non-standard web mercator GeoJSON can be created by passing 'mercator'. buffer : float, optional Optional buffer distance for the GeoJSON polygon. precision : int, optional GeoJSON coordinates will be truncated to this number of decimal places. Returns ------- dict """ west, south, east, north = bounds(tile) if projected == "mercator": west, south = xy(west, south, truncate=False) east, north = xy(east, north, truncate=False) if buffer: west -= buffer south -= buffer east += buffer north += buffer if precision and precision >= 0: west, south, east, north = ( round(v, precision) for v in (west, south, east, north) ) bbox = [min(west, east), min(south, north), max(west, east), max(south, north)] geom = { "type": "Polygon", "coordinates": [ [[west, south], [west, north], [east, north], [east, south], [west, south]] ], } xyz = str(tile) feat = { "type": "Feature", "bbox": bbox, "id": xyz, "geometry": geom, "properties": {"title": "XYZ tile %s" % xyz}, } if props: feat["properties"].update(props) if fid is not None: feat["id"] = fid return feat def _coords(obj): """All coordinate tuples from a geometry or feature or collection Yields ------ lng : float Longitude lat : float Latitude """ if isinstance(obj, (tuple, list)): coordinates = obj elif "features" in obj: coordinates = [feat["geometry"]["coordinates"] for feat in obj["features"]] elif "geometry" in obj: coordinates = obj["geometry"]["coordinates"] else: coordinates = obj.get("coordinates", obj) for e in coordinates: if isinstance(e, (float, int)): yield tuple(coordinates) break else: for f in _coords(e): yield f[:2] def geojson_bounds(obj): """Returns the bounding box of a GeoJSON object Parameters ---------- obj : mapping A GeoJSON geometry, feature, or feature collection. Returns ------- LngLatBbox """ def func(bbox, coords): w, s, e, n = bbox lng, lat = coords return min(w, lng), min(s, lat), max(e, lng), max(n, lat) w, s, e, n = reduce(func, _coords(obj), (180.0, 90.0, -180.0, -90.0)) return LngLatBbox(w, s, e, n) @lru_cache(maxsize=28) def minmax(zoom): """Minimum and maximum tile coordinates for a zoom level Parameters ---------- zoom : int The web mercator zoom level. Returns ------- minimum : int Minimum tile coordinate (note: always 0). maximum : int Maximum tile coordinate (2 ** zoom - 1). Raises ------ InvalidZoomError If zoom level is not a positive integer. Examples -------- >>> minmax(1) (0, 1) >>> minmax(-1) Traceback (most recent call last): ... InvalidZoomError: zoom must be a positive integer """ try: if int(zoom) != zoom or zoom < 0: raise InvalidZoomError("zoom must be a positive integer") except ValueError: raise InvalidZoomError("zoom must be a positive integer") return (0, 2 ** zoom - 1)