How to Make a 3D Map With pydeck in Python

On this page

Problem statement

A 3D map, with columns or hexagons rising from the ground, shows quantities as heights that the eye compares easily across a whole country. pydeck, the Python binding for the deck.gl WebGL library, makes such maps from a DataFrame: a ColumnLayer draws a column at each point, a HexagonLayer aggregates points into hexagons extruded by their count, and the view can be tilted and rotated. The parameters that decide whether the result is readable are the elevation scale, the radius in metres, the colours, which are given as RGB lists, and the camera.

This page draws the 2023 population of every county in the conterminous US as 3D columns, aggregates 24,000 airfields in the conterminous US into extruded hexagons, colours the columns by density, fits the camera to the data, and saves each map as a standalone HTML file.

Quick answer

Build a layer from a DataFrame, set a tilted view, and save the deck:

import pydeck as pdk
layer = pdk.Layer("ColumnLayer", D, get_position=["lon", "lat"], get_elevation="POPESTIMATE2023", elevation_scale=0.04,
                  radius=9000, get_fill_color="rgb", pickable=True, auto_highlight=True)
view = pdk.ViewState(latitude=37.5, longitude=-96, zoom=3.6, pitch=50, bearing=-12)
deck = pdk.Deck(layers=[layer], initial_view_state=view, map_style="light", tooltip={"text": "{NAME}\n{pop_txt} people"})
deck.to_html("data/county_columns.html", open_browser=False, notebook_display=False)
print(f"columns: {len(D):,}; tallest {D.POPESTIMATE2023.max() * 0.04 / 1000:,.0f} km ({D.loc[D.POPESTIMATE2023.idxmax(), 'NAME']}); HTML {Path('data/county_columns.html').stat().st_size / 1e6:.2f} MB")
columns: 3,109; tallest 387 km (Los Angeles County, California); HTML 1.03 MB

The tallest of the 3,109 columns is Los Angeles County, at 387 km. The page is 1.03 MB.

Screenshot of a tilted pydeck map of the conterminous US with a thin column at each county, tallest at Los Angeles, Chicago, Houston and Phoenix, coloured from purple for sparse to yellow for dense. Screenshot of a tilted pydeck map of the conterminous US with a thin column at each county, tallest at Los Angeles, Chicago, Houston and Phoenix, coloured from purple for sparse to yellow for dense.
County populations as 3D columns.

Elevation scale, RGB colours, hexagons from points

1. Elevation scale

Heights are in metres: get_elevation times elevation_scale. With a scale of 1, Los Angeles County would be a column almost 10,000 km tall. Pick the scale from the largest value and the height you want it to have:

target_km = 400
scale = target_km * 1000 / D.POPESTIMATE2023.max()
print(f"largest value {D.POPESTIMATE2023.max():,}; scale for a {target_km} km column: {scale:.3f}")
print(f"median county at that scale: {D.POPESTIMATE2023.median() * scale / 1000:.1f} km tall")
largest value 9,663,345; scale for a 400 km column: 0.041
median county at that scale: 1.1 km tall

Los Angeles County's 9,663,345 people need a scale of 0.041 for a 400 km column, and at that scale the median county is just 1.1 km tall. That is the nature of a 3D column map: a few very large values dominate it.

2. Colours as RGB lists

deck.gl takes colours as [r, g, b] or [r, g, b, a] lists, not names or hex strings. Build them from a matplotlib colormap on log density:

print(f"first rows of the rgb column: {D.rgb.head(3).tolist()}")
print(f"density range coloured: {D.density_km2.min():.2f} to {D.density_km2.max():,.0f} people per kmΒ² on a log scale")
first rows of the rgb column: [[41, 121, 142], [50, 99, 141], [129, 211, 76]]
density range coloured: 0.02 to 17,227 people per kmΒ² on a log scale

Each row carries its own [r, g, b] list from the viridis colormap, across densities from 0.02 to 17,227 people per kmΒ² on a log scale.

Column heights of the five most populous counties at an elevation scale of 0.04. column height at elevation_scale 0.04Los Angeles, CA387 kmCook, IL203 kmHarris, TX193 kmMaricopa, AZ183 kmmedian county1.0 km0100 km200 km300 km400 kmCensus Vintage 2023: a few counties dominate a 3D column map spatialworkflow.io
How a few values dominate the heights.

3. Hexagons from points

HexagonLayer bins points into hexagons in the browser and extrudes each by its count. Aggregate all US airfields at a 25 km radius:

hexes = pdk.Layer("HexagonLayer", US, get_position=["longitude_deg", "latitude_deg"], radius=25000, elevation_scale=300,
                  elevation_range=[0, 1000], extruded=True, coverage=0.9, pickable=True)
deck2 = pdk.Deck(layers=[hexes], initial_view_state=pdk.ViewState(latitude=37.5, longitude=-96, zoom=3.6, pitch=45, bearing=10), map_style="light",
                 tooltip={"text": "{elevationValue} airfields"})
deck2.to_html("data/airfield_hexagons.html", open_browser=False, notebook_display=False)
print(f"points: {len(US):,}; HTML {Path('data/airfield_hexagons.html').stat().st_size / 1e6:.2f} MB")
points: 24,062; HTML 3.10 MB

All 24,062 points go into the page as JSON, 3.10 MB, three times the county page; the hexagon counts are computed in the browser.

Fitting the camera to the data

The data used on this page

The US Census Bureau's 2023 county cartographic boundaries and Vintage 2023 population estimates, reduced to one point per county inside its boundary, and every open place in the conterminous US from OurAirports. The light map style is CARTO's Positron vector basemap, which pydeck loads without a key.

from pathlib import Path

import geopandas as gpd
import matplotlib
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
def cached(path, url):
    p = Path(path)
    if not p.exists():
        r = requests.get(url, timeout=600)
        r.raise_for_status()
        p.write_bytes(r.content)
    return p
C = gpd.read_file(cached("data/cb_2023_us_county_20m.zip", "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip"))
est = pd.read_csv(cached("data/co-est2023-alldata.csv", "https://www2.census.gov/programs-surveys/popest/datasets/2020-2023/counties/totals/co-est2023-alldata.csv"),
                  encoding="latin-1", dtype={"STATE": str, "COUNTY": str})
est = est[est.SUMLEV == 50].assign(GEOID=lambda d: d.STATE + d.COUNTY)[["GEOID", "STNAME", "CTYNAME", "POPESTIMATE2023"]]
G = C[~C.STATEFP.isin(["02", "15", "72"])].merge(est, on="GEOID")
G["density_km2"] = G.POPESTIMATE2023 / (G.to_crs(5070).area / 1e6)
pt = G.to_crs(5070).representative_point().to_crs(4326)
D = pd.DataFrame({"NAME": G.CTYNAME + ", " + G.STNAME, "lon": pt.x, "lat": pt.y, "POPESTIMATE2023": G.POPESTIMATE2023, "density_km2": G.density_km2})
D["pop_txt"] = D.POPESTIMATE2023.map("{:,}".format)
norm = matplotlib.colors.LogNorm(vmin=max(D.density_km2.min(), 0.1), vmax=D.density_km2.max())
D["rgb"] = [[int(v * 255) for v in matplotlib.colormaps["viridis"](norm(d))[:3]] for d in D.density_km2]
A = pd.read_csv(cached("data/airports.csv", "https://davidmegginson.github.io/ourairports-data/airports.csv"), keep_default_na=False, na_values=[""])
US = A[(A.iso_country == "US") & (A.type != "closed") & A.longitude_deg.between(-130, -60) & A.latitude_deg.between(24, 50)][["ident", "longitude_deg", "latitude_deg"]]
print(f"{len(D):,} counties; {len(US):,} airfields in the conterminous US")
3,109 counties; 24,062 airfields in the conterminous US

The data is one point for each of the 3,109 counties, and 24,062 open airfields in the conterminous US.

Example 1: fit the camera to the data

pdk.data_utils.compute_view picks a centre and zoom that contain most of the points:

v = pdk.data_utils.compute_view(D[["lon", "lat"]], view_proportion=0.95)
print(f"computed view: latitude {v.latitude:.2f}, longitude {v.longitude:.2f}, zoom {v.zoom:.2f}")
v.pitch, v.bearing = 50, -12
deck.initial_view_state = v
deck.to_html("data/county_columns_fitted.html", open_browser=False, notebook_display=False)
computed view: latitude 38.28, longitude -91.66, zoom 2.00

compute_view centres the view at 38.28Β° N, 91.66Β° W with zoom 2.00. That is wider than the zoom of 3.6 chosen by hand, so check a computed view before using it.

Screenshot of a tilted pydeck hexagon map of US airfields in 25 km hexagons, with a tall red hexagon in the Los Angeles area and shorter orange ones around other cities. Screenshot of a tilted pydeck hexagon map of US airfields in 25 km hexagons, with a tall red hexagon in the Los Angeles area and shorter orange ones around other cities.
Airfields aggregated into hexagons in the browser.

pydeck writes a deck.gl description into a page

How pydeck turns a DataFrame into a 3D map. DataFrame to 3D mapDataFramelon, lat, value,rgbpdk.Layerget_ accessors perrowViewStatepitch 50, bearing-12JSON in HTML1.03 MB for 3,109rowsdeck.gl, WebGLdrawn on the GPUpydeck writes a deck.gl page spatialworkflow.io
How pydeck builds the page.

pydeck builds a JSON description of a deck.gl visualisation (layers, data, view state, basemap) and writes it into an HTML page that loads deck.gl from a CDN. The data travels inside the page as JSON, so the file grows with the number of rows and columns passed to each layer. deck.gl draws on the GPU with WebGL, which is why it can extrude thousands of columns and still rotate smoothly. Layer properties starting with get_ are accessors, read per row from a column name or an expression, while others such as radius and elevation_scale apply to the whole layer.

3D adds a dimension but also hides things. Tall columns block the ones behind them, the tilt makes distant columns smaller, and heights are hard to compare across the screen. A 3D map works best for a single striking quantity, such as where the people are, and should be checked against a flat map or a table. Aggregating layers like HexagonLayer compute in the browser, so their counts are not available in Python. To report the numbers, compute the same bins with H3 or a grid.

Mapbox styles, tilting and large data

Checks before sharing a pydeck 3D map. before sharing a 3D mapelevation_scalelargest value about 400 kmcolours as [r, g, b]not names or hexradius in metres9 km columns, 25 km hexagonscheck a flat map tootall columns hide othersmap_style without a keylight, dark, roadpydeck ColumnLayer and HexagonLayer spatialworkflow.io
Before sharing a 3D map.
  • Mapbox styles: Mapbox map styles need an API key passed as api_keys={"mapbox": ...}; the CARTO styles light, dark and road do not.
  • Hold Ctrl and drag (or right-drag) to rotate and tilt in the browser.
  • Large data: for hundreds of thousands of points, pass only the columns needed, or use lonboard, which sends binary data instead of JSON.
  • Jupyter: deck.show() displays inline; to_html(notebook_display=False) writes a file without displaying.

FAQ

How do I make a 3D map in Python?

Use pydeck with a ColumnLayer or HexagonLayer, set pitch in the ViewState to tilt the view, and save with deck.to_html().

How do I set colours in pydeck?

Give colours as lists of [r, g, b] or [r, g, b, a] integers from 0 to 255, either fixed or from a DataFrame column.

Why are my pydeck columns too tall or invisible?

Heights are in metres; set elevation_scale so the largest value gives a height of a few hundred kilometres at a national scale.

Does pydeck need a Mapbox token?

Only for Mapbox styles; the built-in CARTO styles work without one.