Understanding slippy map tiles, and how they vary with latitude and zoom level.
projection
python
pydeck
Author
Tyler Erickson
Published
September 9, 2024
The internet is full of interactive tiled slippy web maps. These maps load tiles as needed in response to the user zooming and panning around, which allows users to explore extremely large datasets with minimal lagtime. This style of web map was first popularized by Google Maps in 2005, and since then the approach has been adopted by other mapping libraries, such as Leaflet.js, OpenLayers, Mapbox and many others. These interactive maps utilize an XYZ tiling scheme, where Z represents the zoom level and X and Y are denote the tile indices.
These tiles use a Web (pseudo) Mercator projection (EPSG:3857), which is defined using a strange mix of ellipsoidal and spherical properties. If you care about positional accuracy, use this projection at your own risk. Also the projection strongly distorts areas. For example, compare how Greenland and Africa appear to be similar in size (2.2 million \(\text{km}^2\) and 30.4 million \(\text{km}^2\), respectively).
But if you are creating an interactive map for the web, the projection can be very useful.
Mathmatical Description
The Web Mercator projection is defined by the following formulas. The tile’s \(x\)-index is a function of longitude (\(\lambda\)) and zoom level (\(z\)): \[
x = {\frac {1}{2\pi }}\cdot 2^z\left(\pi +\lambda \right)
\tag{1}\] and the tile’s \(y\)-index is a function of latitude (\(\varphi\)): \[
y = \frac{1}{2\pi } \cdot 2^z\left(\pi -\ln \left[\tan \left({\frac {\pi }{4}} + \frac{\varphi }{2}\right)\right]\right)
\tag{2}\]
Rearranging Equation 1 to solve for longitude (\(\lambda\)) yields: \[
\lambda = \frac{2\pi \cdot x}{2^z} - \pi
\tag{3}\]
and rearranging Equation 2 to solve for latitude (\(\varphi\)) yields: \[
\varphi = 2 \cdot \arctan(e^{\pi - \frac{2\pi \cdot y}{2^z}}) - {\frac {\pi }{2}}
\tag{4}\]
Python Implementation
In Python those equations can be written as:
Code: Define XYZ to lon/lat function
import numpy as npimport numpy.typing as nptdef xyz_to_lonlat( x: npt.NDArray[np.floating], # horizontal index y: npt.NDArray[np.floating], # vertical index z: int, # zoom level): lon =2* np.pi * x /2**z - np.pi lat =2* np.arctan(np.exp(np.pi - (2* np.pi * y /2**z))) - np.pi /2return (lon *180/ np.pi, lat *180/ np.pi)
1
NumPy, the “fundamental package for scientific computing with Python” (https://numpy.org/)
2
NumPy type hinting (https://numpy.org/doc/1.21/reference/typing.html)
To understand how tiles differ between levels and across the Earth, we create a GeoDataFrame object containing the tile information.
Code: Create GeoDataFrames with tile information
import geopandas as gpdimport shapelyzoom_levels = [0, 1, 2, 3, 4]# for layer in layer_config_list:tile_data_list = []for zoom in zoom_levels: tilerange =range(2**zoom +1)# Create arrays of cell vertices. x_vertices = np.array(tilerange) y_vertices = np.array(tilerange)# Convert vertices to lon/lat coordinates. lons, lats = xyz_to_lonlat(x_vertices, y_vertices, zoom)for ix in tilerange[:-1]:for iy in tilerange[:-1]: poly_coords = [ (lons[ix], lats[iy]), (lons[ix +1], lats[iy]), (lons[ix +1], lats[iy +1]), (lons[ix], lats[iy +1]) ]# print('poly_coords', poly_coords) tile_data = {'zoom': zoom,'x_index': ix,'y_index': iy,'geometry': shapely.Polygon(poly_coords), } tile_data_list.append(tile_data)gdf = gpd.GeoDataFrame( data=tile_data_list, crs="EPSG:4326")
Based on code described in this earlier post, we can calculate the area covered by each tile.
Code: Create GeoDataFrames with tile information
import mathdef patch_area_single_rectangle( R: float, # Authalic radius, in meters lon1: float, # Minimum longitude, in degrees lat1: float, # Minimum latitude, in degrees lon2: float, # Maximum longitude, in degrees lat2: float, # Maximum latitude, in degrees) ->float:# Convert from degrees to radians lon1_rad = lon1 * (np.pi /180) lon2_rad = lon2 * (np.pi /180) lat1_rad = lat1 * (np.pi /180) lat2_rad = lat2 * (np.pi /180) h = R * (math.sin(lat2_rad) - math.sin(lat1_rad)) area_patch = R * h * (lon2_rad - lon1_rad)return area_patchgdf['area_km2'] = gdf.apply(lambda row:patch_area_single_rectangle(6371007.180918, # Authalic radius, in meters*row['geometry'].bounds, # tuple of (minx, miny, maxx, maxy) ) *1e-6, axis=1)
Let’s see a summary of this tile dataset.
Code: Display dataset sample
gdf
zoom
x_index
y_index
geometry
area_km2
0
0
0
0
POLYGON ((-180 85.05113, 180 85.05113, 180 -85...
5.081641e+08
1
1
0
0
POLYGON ((-180 85.05113, 0 85.05113, 0 0, -180...
1.270410e+08
2
1
0
1
POLYGON ((-180 0, 0 0, 0 -85.05113, -180 -85.0...
1.270410e+08
3
1
1
0
POLYGON ((0 85.05113, 180 85.05113, 180 0, 0 0...
1.270410e+08
4
1
1
1
POLYGON ((0 0, 180 0, 180 -85.05113, 0 -85.051...
1.270410e+08
...
...
...
...
...
...
336
4
15
11
POLYGON ((157.5 -55.77657, 180 -55.77657, 180 ...
1.439368e+06
337
4
15
12
POLYGON ((157.5 -66.51326, 180 -66.51326, 180 ...
7.045811e+05
338
4
15
13
POLYGON ((157.5 -74.01954, 180 -74.01954, 180 ...
3.321439e+05
339
4
15
14
POLYGON ((157.5 -79.17133, 180 -79.17133, 180 ...
1.537909e+05
340
4
15
15
POLYGON ((157.5 -82.67628, 180 -82.67628, 180 ...
7.061717e+04
341 rows × 5 columns
Among other things, the data table shows that tile areas vary by y_index and zoom level. But it is difficult to grasp how these values change around the Earth from a table.
Tile Visualization
This section creates visualizations of Web Mercator tiles for various zoom levels. We start by formatting the tile data so that can be used by PyDeck.
Code: Add PyDeck columns
tile_colors = [ [0, 0, 0], [255, 255, 255],]def add_pydeck_colums(gdf):"""Add columns for PyDeck visualization"""# Convert geodataframe geometries into a PyDeck compatible format gdf['pydeck_polygon'] = [ [ vertex + (200000, ) # Add a third dimensionfor vertex inlist(geom.exterior.coords) ]for geom in gdf['geometry'].geometry ] gdf['pydeck_centroid'] = [list(geom.centroid.coords[0]) + [800000] # Add a third dimensionfor geom in gdf['geometry'].geometry ] gdf['centroid_label'] ='('+ gdf['x_index'].astype(str) +','+ gdf['y_index'].astype(str) +')' gdf['bounds'] = [ [f'{x:.2f}'for x in geom.bounds]for geom in gdf['geometry'].geometry ] gdf['fill_color'] = gdf.apply(lambda row: tile_colors[(row['x_index'] + row['y_index']) %2], axis=1 ) gdf['area_desc'] = gdf.apply(lambda row: f"{row['area_km2']:.2e}", axis=1 )return gdfgdf = add_pydeck_colums(gdf)
We then create a function that can generate PyDeck objects for a specified zoom level.
Code: Create PyDeck globes to display the tiles
import pydeck as pdkfrom pydeck.types import Stringimport pandas as pddef get_globe_view():"""Return a pyDeck view for the globe.""" view = pdk.View(type="_GlobeView", controller=True, width='100%', )return viewdef get_initial_view_state():"""Specify the initial pyDeck view state.""" initial_view_state = pdk.ViewState( latitude=45, longitude=-45, zoom=0, min_zoom=0, )return initial_view_statedef get_basemap_layer():"""Get a pyDeck layer based on Natural Earth country boundaries.""" basemap_layer = pdk.Layer("GeoJsonLayer",id="base-map", data="https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson", stroked=False, filled=True, extruded=False, get_fill_color=[200, 200, 200], get_line_color=[255, 255, 255], opacity=0.5, )return basemap_layerdef get_tiles(zoom):"""Create a pyDeck polygon and text layers for tiles of a specified zoom level.""" poly_layer = pdk.Layer("SolidPolygonLayer", gdf[gdf['zoom']==zoom], opacity=0.25, visible=True, get_polygon="pydeck_polygon", get_fill_color="fill_color", extruded=True, ) text_layer = pdk.Layer("TextLayer", gdf[gdf['zoom']==zoom], get_position="pydeck_centroid", get_text="centroid_label", get_size=12,# get_color=[127, 255, 255], get_color=[255, 255, 0], get_angle=0, get_text_anchor=String("middle"), get_alignment_baseline=String("center"), pickable=True, extruded=True, )return [poly_layer, text_layer]def create_globe_with_xyz_tiles(zoom):# gdf = layer_config['gdf'] deck = pdk.Deck( views=[get_globe_view()], initial_view_state=get_initial_view_state(), layers=[ get_basemap_layer(), get_tiles(zoom), ], map_provider=None, tooltip={"html": ("<table>""<tr><th>Zoom:</th><td>{zoom}</td></tr>""<tr><th>X, Y:</th><td>{x_index}, {y_index}</td></tr>""<tr><th>Area, km<sup>2</sup>:</th><td>{area_desc}</td></tr>""<tr><th>Longitude:</th><td>{bounds.0}° to {bounds.2}°</td></tr>""<tr><th>Latitude:</th><td>{bounds.1}° to {bounds.3}°</td></tr>""</table>" ),"style": {"text-align": "right", } },# Note: set to True for the globe to be opaque parameters={"cull": True}, )return deck
The following figures show how the Web Mercator tiles are distributed on the globe for zoom levels 1 through 4. Viewing Web Mercator tiles this way clearly shows that the tiles do not reach the poles, regardless of the zoom level. Rather the tiles end at latitudes \(85.051\degree\) N and \(85.051\degree\) S1.
Tiles - Zoom Level 1
For zoom level 1, the x and y tile indices range from 0 to 12. There are a total of 4 tiles3.
Code: Display PyDeck globe for zoom level 1
create_globe_with_xyz_tiles(1)
Tiles - Zoom Level 2
For zoom level 2, the x and y tile indices range from 0 to 34. There are a total of 16 tiles5.
Code: Display PyDeck globe for zoom level 2
create_globe_with_xyz_tiles(2)
Tiles - Zoom Level 3
For zoom level 3, the x and y tile indices range from 0 to 76. There are a total of 64 tiles7.
Code: Display PyDeck globe for zoom level 3
create_globe_with_xyz_tiles(3)
Tiles - Zoom Level 4
For zoom level 4, the x and y tile indices range from 0 to 158. There are a total of 256 tiles9.