Points, in two lines add_circle_markers
No column mapping, no projection argument, no view to set. lat/lon are found by name and the map fits itself to whatever you add.
m = Map()
m.add_circle_markers(sensors, name="Sensors")
swiftmap keeps Leaflet's ergonomics — a few add_* calls, no
projection argument, no view to set — and replaces its vector drawing with
WebGL pipelines. This is not a screenshot. Drag it.
Synthetic western-Mediterranean vessel traffic, coloured by speed. The tiers are the same field at three densities — 19.1 MB of float64 coordinates for the largest, fetched only when you ask for it. The frame counter reads while you drag, because an idle map runs at your display's refresh rate no matter what is on it.
# traffic: 1,000,000 AIS pings -- lane, ferry, coastal, fishing, anchorage.
m = Map()
m.select("World Gray Canvas", scope="Basemaps") # a bare Map() ships two
m.add_circle_markers(traffic, name="Vessel traffic", layer_group="AIS",
radius=2, color_col="speed_kn",
colormap="turbo", vmin=0, vmax=21)
m.configure_legend(show=True, title="Speed over ground (kn)")
Not a screenshot and not a video — a real map you can pan, toggle and scrub, built by running the code printed under it. The snippets are the strings this page's generator executed, so a card cannot drift from its example.
import numpy as np
import pandas as pd
from swiftmap import Map
rng = np.random.default_rng(42)
# 600 harbour sensors around the Strait of Gibraltar.
n = 600
sensors = pd.DataFrame({
"lat": 36.02 + rng.normal(0, 0.05, n),
"lon": -5.45 + rng.normal(0, 0.09, n),
"site": [f"S{i:03d}" for i in range(n)],
"station": rng.choice(["Alpha", "Bravo", "Charlie", "Delta"], n),
"region": rng.choice(["East", "West"], n),
"status": rng.choice(["Active", "Idle", "Fault"], n, p=[.70, .22, .08]),
"reading": np.round(rng.gamma(4, 4, n), 1),
"volume": rng.integers(10, 500, n),
})
# Three vessels, one ping every half hour for a day.
steps = 48
pings = pd.concat([
pd.DataFrame({
"vessel": vessel,
"lat": lat0 + np.cumsum(rng.normal(0.002, 0.003, steps)),
"lon": lon0 + np.cumsum(rng.normal(0.009, 0.004, steps)),
"timestamp": pd.date_range("2026-08-01", periods=steps,
freq="30min", tz="UTC"),
})
for vessel, (lat0, lon0) in [("Vessel A", (36.00, -5.85)),
("Vessel B", (35.94, -5.78)),
("Vessel C", (36.08, -5.90))]
], ignore_index=True)
# Two dwell zones, each with a risk rating and a lifetime.
zones = pd.DataFrame({
"zone": ["North"] * 4 + ["South"] * 4,
"vertex": [0, 1, 2, 3] * 2,
"lat": [36.10, 36.10, 36.16, 36.16, 35.90, 35.90, 35.96, 35.96],
"lon": [-5.55, -5.40, -5.40, -5.55, -5.55, -5.40, -5.40, -5.55],
"risk": ["high"] * 4 + ["medium"] * 4,
"times": [["2026-08-01 02:00", "2026-08-01 14:00"]] * 4
+ [["2026-08-01 08:00", "2026-08-01 22:00"]] * 4,
})
# 8,100 detections in four clusters of very different density, for the
# density kernels.
detections = pd.concat([
pd.DataFrame({"lat": clat + rng.normal(0, s, k),
"lon": clon + rng.normal(0, s * 1.6, k),
"weight": rng.gamma(2, 2, k)})
for k, clat, clon, s in [(4500, 36.05, -5.45, 0.018),
(2000, 36.18, -5.28, 0.024),
( 700, 35.92, -5.62, 0.030),
( 900, 36.05, -5.45, 0.060)]
], ignore_index=True)
No column mapping, no projection argument, no view to set. lat/lon are found by name and the map fits itself to whatever you add.
m = Map()
m.add_circle_markers(sensors, name="Sensors")
One keyword puts the data through a colormap, and the legend derives itself from the same fact. The colours are computed in Python and travel as a binary buffer, not as per-feature JSON.
m = Map()
m.add_circle_markers(sensors, name="Sensors", color_col="reading",
colormap="viridis")
m.configure_legend(show=True, title="Reading (ppm)")
Radii are area-proportional -- the radius grows with the square root -- so a doubled value looks doubled instead of quadrupled.
m = Map()
m.add_circle_markers(sensors, name="Sensors",
color_col="reading", radius_col="volume",
radius_range=(3, 18))
m.configure_legend(show=True, title="Volume")
A non-numeric column takes categorical colours automatically; a {value: colour} mapping pins them, and the legend reads in the mapping's order.
m = Map()
m.add_circle_markers(sensors, name="Status", color_col="status",
colormap={"Active": "#59a14f", "Idle": "#4e79a7",
"Fault": "#e15759"})
m.configure_legend(show=True, title="Sensor status")
Bin edges turn a continuous ramp into discrete classes, and the legend switches from a gradient to labelled swatches to match.
m = Map()
m.add_circle_markers(sensors, name="Banded", color_col="reading",
color_bins=[10, 20, 30], colormap="turbo")
m.configure_legend(show=True, title="Reading band")
A list of column names becomes a folder tree: region, then status, then a layer per station. Toggling a folder costs about a hundred bytes on the wire, at any scale.
m = Map()
m.add_circle_markers(sensors, name="station",
layer_group=["Sensors", "region", "status"],
color_col="status")
m.configure_group("Sensors", collapsed=False)
The derived legend is the starting point, not the ceiling: manual rows sit alongside it, grouped, in any of the four corners.
m = Map()
m.add_circle_markers(sensors, name="Sensors", color_col="reading")
m.configure_legend(show=True, title="Harbour key", position="bottom-right")
m.legend_add("Restricted zone", shape="polygon", color="#e15759",
fill_color="#e1575944", group="Areas")
m.legend_add("Confidence", categories=["confirmed", "probable", "ruled out"])
Press play: the pings sweep along their routes, which stay put because only the point layer is timed. Point time filtering runs on the GPU -- the per-point timestamps upload once as vertex attributes and each tick is a single uniform, so scrubbing re-sends nothing.
m = Map()
m.add_line(pings, line_id_col="vessel", order_col="timestamp",
name="vessel", layer_group="Routes", color="#b0b7be", weight=2)
m.add_circle_markers(pings, name="Pings", color_col="vessel", radius=7)
m.make_time_layer("Pings", duration="PT6H", fade=True)
m.configure_time(period="PT1H", speed=2, loop=True, position="bottom-center")
An id column and an order column are enough -- no grouping, no geometry construction. WKT, GeoJSON and GeoPandas work the same way.
m = Map()
m.add_line(pings, line_id_col="vessel", order_col="timestamp",
name="vessel", color_col="vessel", weight=3)
m.configure_legend(show=True, title="Tracks")
Interior holes and MultiPolygons render correctly. label= takes a column name and chips every shape with its own value.
m = Map()
m.add_polygon(zones, shape_id_col="zone", order_col="vertex",
name="zone", label="zone", color="crimson",
fill_opacity=0.25, popup_fields=["risk"], popup_names=["Risk"])
m.add_circle_markers(sensors, name="Sensors", radius=4, color="#4e79a7")
Points, lines and areas in the same frame: swiftmap makes one layer per geometry kind and merges them under a single sidebar entry.
survey = pd.DataFrame({
"geometry": ["POINT (-5.36 36.13)",
"LINESTRING (-5.44 36.05, -5.36 36.09, -5.30 36.14)",
"POLYGON ((-5.42 36.00, -5.32 36.00, -5.32 36.06, "
"-5.42 36.06, -5.42 36.00))"],
"label": ["Buoy", "Route", "Zone"],
})
m = Map()
m.add_collection(survey, name="label", layer_group="Survey")
The classic heat blob, recomputed per frame on the GPU. Zoom in and the field re-normalises to what is on screen.
m = Map()
m.add_heatmap(detections, name="Density", radius=40)
m.configure_legend(show=True, title="Detections")
The same points binned into H3 hexagons and filled by their sums -- cells that carry their own ids, so a click tells you which cell and how many.
m = Map()
m.add_heatmap(detections, cells="h3", resolution=6,
weight_col="weight", name="Hex density")
m.configure_legend(show=True, title="Weight per cell")
The other cell family, same kernel. Rectangles instead of hexagons, and base= picks between geohash-32 and Niemeyer-16.
m = Map()
m.add_heatmap(detections, cells="geohash", length=5, base=32,
name="Geohash density")
m.configure_legend(show=True, title="Detections per cell")
Leaflet-Geoman's toolbar, wired to a synced trait. In a notebook or a Shiny app m.drawings updates in Python as you draw; here there is no Python behind the page, so the shapes stay put.
m = Map()
m.add_circle_markers(sensors, name="Sensors", color_col="reading")
m.configure_draw(show=True, tools=["rectangle", "polygon", "circle"])
m.drawings = [{"type": "Feature", "properties": {"name": "Patrol box"},
"geometry": {"type": "Polygon", "coordinates": [[
[-5.55, 35.98], [-5.35, 35.98], [-5.35, 36.08],
[-5.55, 36.08], [-5.55, 35.98]]]}}]
A column name labels each feature from its own value. Labels are DOM elements, so they are for sites and zones -- the point builders warn past a thousand.
m = Map()
m.add_markers(sensors.head(12), name="Sites", label="site")
Click a point for the popup, hover for the tooltip. Fields choose the columns, names relabel them, and every value is HTML-escaped.
m = Map()
m.add_circle_markers(sensors, name="Sensors", color_col="reading", radius=7,
popup_fields=["site", "reading", "volume", "status"],
popup_names=["Site", "Reading (ppm)", "Volume", "Status"],
tooltip_fields=["site"])
Any xyzservices catalogue name, a WMS endpoint, or a raw {z}/{x}/{y} template. Open the sidebar and switch between them.
m = Map()
m.add_basemap("Esri.WorldImagery")
m.add_basemap("CartoDB.DarkMatter")
m.add_circle_markers(sensors, name="Sensors", color="#ffcc00", radius=5)
Both are declarative and total: each call states the whole selection, so switching needs no undoing and select(None) puts everything back. The highlight sits above the layer's own styling, not instead of it.
m = Map()
m.add_polygon(zones, shape_id_col="zone", order_col="vertex",
name="zone", layer_group="Dwells", fill_opacity=0.2)
m.add_line(pings, line_id_col="vessel", order_col="timestamp",
name="vessel", layer_group="Vessels", weight=3)
m.select("North", scope="Dwells") # hides South, leaves Vessels alone
m.highlight("Vessel A", color="#ffcc00", lines={"weight": 6})
Anything GDAL reads, warped into the map's CRS at build time; a single band goes through the house colormaps. The source here is UTM 30N, reprojected on the way in.
m = Map()
m.add_imagery("demo_dem.tif", name="Synthetic DEM",
colormap="turbo", vmin=0, vmax=1500, opacity=0.85)
m.add_circle_markers(sensors.head(80), name="Sites",
color_col="reading", radius=5)
One rendering core serves notebooks, Shiny, React and Streamlit, with map state syncing both ways wherever there is a Python side to sync with. These are runnable apps rather than embeds, because “what does a production app look like” is a different question from “what can it do”.
Four runnable apps, from a basic map to a live feed and a draw-an-AOI filter. Map state syncs both ways: pan the map and Python sees the viewport; filter in Python and the map follows.
The core wrapped as a component over the npm package. Leaflet, glify and Geoman stay peer dependencies, so your bundler owns them and nothing is loaded twice.
A bidirectional component, its frontend bundled inside the wheel like the widget and the export -- clicks, draws and the viewport come back to Python on every rerun.
Fourteen notebooks that are the long-form version of the gallery above, and the smoke test that runs on every change. Anywhere anywidget runs: JupyterLab, Notebook, VS Code.
folium and ipyleaflet have the ergonomics but hit a ceiling in the tens of thousands of features and offer flat layer controls. deck.gl scales, but it is a different mental model with a thin Python layer. swiftmap keeps the Leaflet model and adds the scale.
| folium / ipyleaflet | deck.gl (pydeck) | kepler.gl | datashader | swiftmap | |
|---|---|---|---|---|---|
| Points before it stops being usable | tens of thousands | millions | millions | hundreds of millions | millions — drag the map above |
| Mental model | Leaflet | its own layer/view stack | a configurable app | a rasterising pipeline | Leaflet |
| What reaches the browser | JSON per feature | typed arrays | JSON or Arrow | a rendered image | binary float64 buffers |
| Per-feature click and hover | yes | yes | yes | no — pixels, not features | yes, at a million |
| Layer control | flat list | build it yourself | flat list in its own UI | you compose it | hierarchy from your columns |
| Cost of a layer toggle | re-render | re-render | re-render | re-raster | a ~100-byte patch, at any scale |
| Python ↔ map sync | one way | one way | one way | one way | bidirectional (viewport, clicks, draws, time) |
| Time playback | plugin, DOM-based | manual | built in | manual | built in, filtered on the GPU |
| Offline / air-gapped | CDN at view time | CDN at view time | CDN at view time | renders server-side | everything inside the wheel |
| Static share | one HTML file | one HTML file | one HTML file | an image, or a Panel app | one HTML file, sidebar and time included |
| 3D — extrusions, terrain | no | yes | yes | no | not yet |
Measured, not claimed: the hero on the home page draws 1,000,000 points in roughly 800 ms from mount to first paint and stays interactive while you drag it — the readout under it is live, not a graphic. The other columns describe design centres; nothing here times them. Run your own comparison — the code behind every map on this site is printed underneath it.
The same comparison as a page of its own →
A comparison table that wins every row is a pitch, not an analysis. These are the places another library is the better answer today. They are known, they are on the list, and this section shrinks as they land — it has already lost density, raster imagery, WMS, offline, marker clustering and line direction since it was first written.
No extrusions, no terrain, no camera pitch. deck.gl owns this, and a Leaflet-shaped library will always be a step behind it here.
read_mvt brings a vector-tile dataset in as an ordinary table
— the bridge, not a renderer. There is still no vector-tile basemap, so a
style-driven basemap has to come in another way.
add_imagery warps a raster you already have. STAC, COG and Earth
Engine integration is leafmap's territory, not this library's.
Millions of points drag smoothly once they are there. Getting them there still costs more than it should on the very largest maps.
pip install swiftmap
anywidget numpy xyzservices
and nothing else — no compiled bits, no Node