swiftmap
swiftmap is a Python mapping library that keeps Leaflet's ergonomics while replacing its
vector drawing with WebGL pipelines — so the map you build with a few add_* calls keeps
rendering smoothly at millions of points, not thousands.
It targets a gap the existing libraries leave open: folium and ipyleaflet have the ergonomics but hit a ceiling around tens of thousands of features and offer flat layer controls; deck.gl scales but is a different mental model with a thin Python layer. swiftmap keeps the Leaflet mental model, adds WebGL scale, hierarchical layer controls, and bidirectional Python sync.
One rendering core serves four stacks: notebooks (anywhere anywidget runs —
JupyterLab, Jupyter Notebook, VS Code), Shiny for Python,
React, and Streamlit — with map state (viewport,
visibility, clicks, draws, the time slider) syncing reactively in both directions
wherever there is a Python side to sync with. A finished map also exports to a single
static HTML file that opens with no backend at all — see
Sharing a map.
Everything runs offline. Leaflet, glify and Leaflet-Geoman ride inside the bundle that ships in the wheel, so nothing is fetched when a map is viewed — on an air-gapped network the wheel is the whole story.
Installation
pip install swiftmap
Required: anywidget, numpy, and xyzservices (the tile-provider catalogue behind
name-callable basemaps — pure metadata, no compiled bits). Pandas, Polars, GeoPandas,
and geostructures are all supported as data sources but none is a dependency — swiftmap
parses whatever you have installed. The bundled JavaScript ships with the package, so
installing never requires Node — and its map libraries are bundled in, so viewing never
reaches the network either.
Quick start (notebook)
import pandas as pd
from swiftmap import Map
df = pd.DataFrame({
"lat": [36.01, 36.02, 36.03],
"lon": [-5.36, -5.35, -5.34],
"name": ["Point A", "Point B", "Point C"],
"value": [12.4, 8.2, 15.1],
})
m = Map()
m.add_circle_markers(df, name="Sites", color_col="value")
m
Three things worth noticing:
- No column mapping.
lat/lonwere found by name;latitude/longitude,x/y, andlngwork too, andlat_col=/lon_col=override the guess when your names differ. color_col="value"colored each point through a viridis ramp. See Color and size from data.- The view followed the data. A
Map()built withoutcenter/zoomfits itself to whatever is added, and keeps following as layers arrive — until anyone states a view (explicitcenter=/zoom=at construction, afit_bounds()call, or panning the map in the browser), after which it is left alone. Frame something on demand any time withm.fit_bounds(m.bounds_of("Sites")). - Sizing:
Map(height="500px")fixes the widget's height, and assigningm.heightresizes a live map. Unset, it fills whatever container it is in.
Quick start (Shiny)
from shiny import App, ui
from shinywidgets import output_widget, render_widget
from swiftmap import Map
from swiftmap.shiny import map_effect
import polars as pl
app_ui = ui.page_fluid(
ui.h2("Swiftmap"),
ui.input_select("status", "Status", ["All", "Active", "Inactive"]),
output_widget("map_widget"),
)
def server(input, output, session):
df = pl.DataFrame({
"lat": [36.01, 36.02, 36.03],
"lon": [-5.36, -5.35, -5.34],
"name": ["A", "B", "C"],
"status": ["Active", "Inactive", "Active"],
})
@render_widget
def map_widget():
# Built once. Depends on nothing reactive -- see the note below.
m = Map() # no view given: it fits itself to the data
m.add_markers(df, name="name", layer_group=["Points", "status"])
m.configure_group("Points", collapsed=False)
return m
@map_effect(map_widget)
def filter_points(m):
if input.status() == "All":
m.select(None, scope="Points")
else:
m.select(group=f"Points/{input.status()}", scope="Points")
app = App(app_ui, server)
The one Shiny rule: the @render_widget function must depend on nothing reactive.
@render_widget rebuilds the widget whenever a dependency invalidates, which throws the
map away and re-uploads every coordinate buffer. Build the map once; make every update
through @map_effect, which resolves the live widget, skips quietly if it has not
rendered yet, and batches everything in the body into one message. map_effect accepts
event= for what @reactive.event would do — do not stack that decorator yourself (it
rejects the map argument at decoration time).
What you can plot
| Method | Draws | Notes |
|---|---|---|
add_markers |
Pin icons | Custom GLSL shader — anti-aliased pins with drop shadows, at scale |
add_circle_markers |
Circle points | radius in pixels; the cheaper primitive, better default for many points |
add_line / add_polyline |
Polylines | Long-format rows (line_id_col, order_col), WKT, GeoJSON, GeoPandas... A MultiLineString is one layer drawn as disjoint parts |
add_polygon (+ add_polygons, add_shape, add_shapes) |
Polygons | Interior holes and MultiPolygons render correctly; one MultiPolygon is one layer |
add_circle |
Geodesic circle | Center plus radius in meters — note the unit difference from circle markers |
add_collection (aliases add_geojson, add_geostructures) |
Every kind in a mixed dataset | One layer per geometry kind, merged under a single sidebar entry; points render as circle markers unless point_type="markers" |
add_basemap |
Tile layer | Any xyzservices catalogue name (~880 providers), a WMS registry name or raw WMS endpoint, or a raw {z}/{x}/{y} template — see Basemaps |
add_imagery |
Georeferenced raster overlay | Anything GDAL reads, warped into the map's CRS; single band through the house colormaps — see Imagery; requires rasterio (optional) |
Every add_* method accepts the same range of inputs:
- Pandas / Polars DataFrames — lat/lon columns found by name; long format via
shape_id_col/line_id_col+order_col; WKT geometry columns recognised by value (point at one explicitly viashape_id_col/line_id_colwhen its name would not be guessed); wide vertex columns (lat1, lon1, lat2, lon2, ...) - GeoPandas GeoDataFrames and GeoSeries
- geostructures shapes,
FeatureCollections, andTracks - GeoJSON dicts or strings
- A bare geometry — a WKT string (
m.add_polygon("POLYGON ((...))")), a shapely geometry, or a geostructures shape — straight in, no table around it - Raw lists and dicts of coordinates, with a range-based heuristic for axis order
(
coord_order="lat_lon"/"lon_lat"to state it explicitly; WKT and GeoJSON declare their own order and are never guessed at)
A method that cannot read what you passed warns and adds nothing rather than raising —
a map is built by a chain of add_* calls, and an exception partway through would discard
the layers already added. Rows with missing coordinates are dropped and counted in a
warning rather than failing the layer.
Basemaps
add_basemap resolves, in order: a preset, a WMS registry name, a raw URL, then the
xyzservices catalogue — ~880 providers, with tolerant lookup ("CartoDB.DarkMatter",
"CartoDB DarkMatter", and "cartodb darkmatter" all land):
m.add_basemap("Esri.WorldImagery", visible=True) # catalogue name
m.add_basemap("USGS Imagery") # WMS, from the registry
m.add_basemap("https://server/wms", wms_layers="0") # raw WMS endpoint
m.add_basemap("https://{s}.tile.../{z}/{x}/{y}.png") # raw XYZ template
m.list_basemaps("dark") searches every accepted name. An unknown name warns and
adds nothing — you never silently get OpenStreetMap when you asked for something
else — and a provider that needs an API key warns by the exact keyword it expects
(accessToken=, apiKey=, ...). WMS requests follow the map's CRS, so an EPSG:4326
map asks in 4326.
Deploying on a network with its own tile and WMS infrastructure — different services, a different catalogue, different defaults — is one swappable module: see DEPLOYING.md.
Imagery
A georeferenced raster — a GeoTIFF, a COG, anything GDAL reads — drops onto the map as an overlay. The raster is reprojected into the map's CRS, so any source projection lands where it belongs, and nodata pixels come out transparent:
m.add_imagery("scene.tif", opacity=0.8) # RGB as-is
m.add_imagery("elevation.tif", colormap="turbo", vmin=0, vmax=1500)
Single-band rasters colour through the same colormaps color_col uses; RGB(A)
rasters keep their own pixels. Large rasters are downsampled to max_size=2048 on
the longest edge on the way in. The overlay behaves like any layer — sidebar
toggle, groups, opacity, static export — and sits above the basemap, below your
vectors. Requires rasterio (pip install rasterio), an optional dependency.
Styling
Every add_* method takes the same styling vocabulary as keyword arguments — color,
fill_color, fill_opacity, weight, opacity, and radius where it applies. Leaflet's
camelCase spellings (fillColor, fillOpacity) work too:
m.add_polygon(zones, color="crimson", fill_color="#f5c4ac", fill_opacity=0.4, weight=2)
Polygons follow Leaflet semantics: the fill reads fill_color and defaults to color
when unset; weight and opacity draw the border. Point layers take their alpha from
the color itself (rgba(...) or #rrggbbaa) rather than from opacity.
Style from your data, row by row. A style property or column is applied per
feature, so one DataFrame can carry its own appearance:
df["style"] = df["kind"].map({"city": "red", "town": "blue"})
m.add_markers(df) # each point takes its own colour
The value is a colour string or a dict of options
({"color": "red", "weight": 5}). Only a column named exactly style is treated this
way — a column that happens to be called color is data, not styling.
Overriding. Precedence runs static_style → explicit keyword → style column →
defaults. static_style={...} forces one appearance for the whole layer regardless of
the data; a plain keyword still beats the column.
Honest warnings. Misspelled options are reported with a suggestion (colour →
color); a real option that this geometry cannot draw says so instead of being silently
ignored. Unrecognised keys that match nothing are passed through to the layer untouched,
which is how custom metadata reaches the frontend.
Color and size from data
Any add_* call can drive color and size from a column, the way the rest of the styling
vocabulary works — no precomputed hex codes, no per-row style dicts:
m.add_circle_markers(df, name="Sensors",
color_col="reading", colormap="plasma",
radius_col="volume", radius_range=(4, 20))
m.add_polygon(districts, name="Districts",
color_col="median_income", colormap="greens") # choropleth:
# fill ramps, border keeps `color`
- Numeric columns ramp between the data's own extremes, or between
vmin/vmaxwhen you fix them.color_bins=[10, 20, 50]classifies into discrete classes instead of a continuous ramp. Missing values (NaN) paint as the layer's base color. - Non-numeric columns are categories: each distinct value takes a palette colour
automatically. Naming a sequential colormap instead spreads it evenly across the
categories. To say exactly which value gets which colour — and the order the legend
reads in — pass a mapping:
colormap={"high": "#d7191c", "medium": "#fdae61", "low": "#1a9641"}; a value the mapping does not name takes the layer colour, and every value it does name is listed in the legend whether or not the data carries it yet, so a feed's legend holds still as it fills in. - Colormaps:
viridis(default),plasma,inferno,magma,turbo,coolwarm,blues,reds,greens,greys; categorical paletteswift10. They are small anchor tables interpolated in RGB — matplotlib-faithful with no matplotlib dependency. Bring your own too: a list of colours, a callablet -> colour(a matplotlibColormapobject works as-is), or"matplotlib:cividis"when matplotlib is installed; andswiftmap.register_colormap("corp", [...])makes a name of it for every call. radius_colsizes points so area is proportional to the value (radius grows with the square root) — a doubled value looks doubled, not quadrupled — acrossradius_rangepixels, default(3, 18).
At scale this matters more than convenience: point colors and radii travel as compact binary buffers beside the coordinates, never as per-feature style dicts in the layer JSON, so coloring five million points does not bloat the payload that every sidebar toggle and view attach has to carry.
The legend below picks all of this up automatically — the ramp, the bins, the
categories, and a stated size row for radius_col (size ∝ volume (10 – 500);
stated, never drawn, because legend pixels are not map pixels at any zoom).
The legend
One call puts a legend on the map, and it derives from the same layer state the map
renders — glyphs per geometry, sections per sidebar folder, ramps/bins/categories
straight from color_col — so there is nothing to fall out of step:
m.configure_legend(show=True, title="Key", position="bottom-right")
scope='all' (default) lists every layer and dims the hidden ones — a legend is the
map's vocabulary; scope='visible' tracks the screen instead, and dim_hidden=False
gives the print-style static key. Eight anchor positions, shared with the time control.
When derivation isn't what you want to say, legend_add writes your own rows — plain
swatches, ramps, or category lists, optionally bound to a live layer's visibility
(layer=) — and legend_remove is a persistent suppressor that keeps matching across
every re-derivation. configure_legend(auto=False) hands the whole legend over to
manual entries. Exports carry it all.
Scale bar
Off by default, one call away, with the unit maritime work runs on alongside the usual ones:
m.show_scale = True
m.configure_scale(units="nautical") # metric (default) | imperial | both | nautical
It measures through the map's CRS — Leaflet's own control underneath, so there is no pixel math of swiftmap's to get wrong — and exports carry it.
Logo card
Off by default, and the branding is yours — swiftmap ships none:
m.configure_logo(company="assets/logo.png", # URL, data URI, or a local file
parent_company={"url": "...", "alt": "Parent"},
position="bottom-right", height=35, show=True)
A local file is read and embedded as a data URI, so it survives a static export and a
machine with no route to the file's home. A slot you never set does not render, and
False clears one. Corner positions, like the draw toolbar — mind that the legend
defaults to the same bottom-left corner.
The sidebar: hierarchy from your data
Layers organise into a tree of folders in the map's sidebar, and the tree can come from
the data itself — any part of layer_group that matches a column name resolves per row:
m.add_markers(df, name="site", # each point named from its own row
layer_group=["Sensor Feeds", "region", "status"])
# -> Sensor Feeds / EMEA / Active, Sensor Feeds / EMEA / Inactive, ...
Toggling a folder toggles everything nested under it. Configure a folder's behaviour with:
m.configure_group("Sensor Feeds", collapsed=True)
m.configure_group("Basemaps", multi_select=False) # radio buttons, not checkboxes
Acting on layers after they exist
One targeting vocabulary — id or name, types=, exclude_types=, group= — is shared
by everything that operates on existing layers:
m.hide(group="Feeds/Inactive")
m.show("Survey") # every part of a collection
m.select(chosen_ids, scope="Dwells", zoom=True, zoom_offset=-1)
m.highlight("Survey", color="#ffcc00", markers={"radius": 14})
m.set_feature_styles("Sites", {12: {"color": "#ffcc00"}}) # one feature, by index
m.fit_bounds(m.bounds_of(["Dwell 3", "Dwell 7"]), zoom_offset=-1)
A layer's data updates in place too, and that is the live-feed primitive: the layer
keeps its id, name, folder, visibility, time animation and highlight while its points
change under it. append=True grows a point layer with the new rows instead:
m.update_layer("Feed", data=latest_df) # replace the data
m.update_layer("Feed", data=new_rows, append=True) # grow it
color_col/radius_col, labels, the legend and bounds re-derive from the new data; a
refresh never moves a viewport you have chosen. One limitation for now: a layer that was
fanned out from a column (name=<column>, layer_group=[..., <column>]) is one of several
siblings and warns rather than guessing how to re-fan.
select and highlight are declarative and total: each call states the complete
selection, so switching needs no undoing and select(None) / highlight(None) restores
a clean slate. Highlights and per-feature overrides sit in fields of their own above the
layer's styling, so clearing them restores what was underneath with nothing remembered.
A call that matches nothing warns — a mistyped name looks identical to a hidden layer, and silence is the failure you would not notice.
Clicks sync back to Python — on features and on open map alike. A feature click sets
m.clicked_layer_id and m.selected_index (overlaps resolved top-down: points over
lines over polygons); a click on open map clears them and reports m.clicked_latlng
as [lat, lon]. Feature clicks record their location too — a point reports its own
coordinates, not the mouse's. m.click_seq bumps on every click, so one observer
reads "where" and "on what" from a single event.
Drawing & AOIs
configure_draw(show=True) puts a draw toolbar on the map — markers, lines,
rectangles, polygons and circles, with vertex editing, dragging and deletion.
Everything drawn lands in m.drawings as GeoJSON features (a circle carries
properties.kind and its radius, since GeoJSON has no circle), and m.draw_seq
bumps on every create, edit and delete — observe that one trait and read
m.drawings in the handler, the same pattern clicks use:
m.configure_draw(show=True, tools=["rectangle", "polygon"])
aoi = m.drawings[0]["geometry"] # after the analyst draws one
The trait syncs both ways: setting m.drawings from Python seeds AOIs onto the map,
clear_drawings() empties it, and exports carry the drawings and the toolbar with
them. The drawing engine (Leaflet-Geoman) is bundled like Leaflet and glify, so the
toolbar works with no network.
Time layers
Any layer whose features carry timestamps can be animated. Timestamps are read from the
layer's own properties — a DataFrame's timestamp column, or the interval a
geostructures Track already records — so nothing extra is passed in:
m.add_circle_markers(df, name="Vessel") # df has a timestamp column
m.make_time_layer("Vessel", period="PT1H")
Ticks land on period boundaries — 04:00, 05:00 for PT1H, not 03:17, 04:17 — so the
slider reads in round numbers, and data arriving earlier than everything before it
only adds ticks at the front instead of shifting every existing one. Wherever the
playhead is, it stays there: it is an absolute moment, not an index into a series that
can move underneath it.
The automatic probe checks the usual names (times, datetime_start/datetime_end,
timestamp, datetime, time, date). When your column is called something else —
or the probe would pick the wrong one — name it yourself, like any other override:
m.make_time_layer("Vessel", time_field="obs_time", period="PT1H")
m.make_time_layer("Dwells", time_field="arrived", time_end_field="departed")
One slider serves every time layer on the map; animating a second layer joins it to the
same slider rather than adding another control. The slider steps through generated
periods ('P1D', 'PT15M', ...) rather than through the observed timestamps, so a
period in which nothing happened still gets its tick — an empty map at 03:00 is a
finding, not a gap in the slider.
By default each tick shows its own period. Pass duration=None to accumulate history
instead, or an ISO8601 duration ('PT6H') for a fixed trailing window. Add fade=True
to dim point features with age — newest at full opacity, reaching zero at the window's
trailing edge. Playback options are shared:
m.configure_time(period="PT1H", auto_play=True, loop=True, speed=2,
position="bottom-center").
The slider's position syncs both ways: m.time_current holds the current tick (epoch
ms) for Shiny to react to, and setting it from Python jumps the slider to that moment.
m.clear_time_layer() stops animating and removes the control.
For point layers the per-tick filtering runs in the vertex shader — dragging the window across five million points repaints in milliseconds, not seconds.
Popups & Tooltips
By default every property is listed as name: value. Narrow the list with *_fields, and give
columns readable labels with *_names:
m.add_markers(
df,
popup_fields=["city", "pop_2020"],
popup_names=["City", "Population"], # matched to fields by position
tooltip_style="background: white; border: 1px solid #333; font-size: 10px;",
)
If *_names cannot be lined up with *_fields, swiftmap warns and falls back to the raw
column names rather than failing the render.
For images, links, or custom layout, supply a template. {column} inserts a single value and
{*} expands the default field list, so you can add markup without enumerating every column:
m.add_markers(
df,
popup_template="<img src='{photo_url}' width=300><br>{*}<br><a href='{source_url}'>source</a>",
popup_fields=["city", "pop_2020"],
popup_names=["City", "Population"],
popup_max_width=800,
)
Your markup renders as HTML; values pulled from the data are escaped. That means a value
containing <script> displays as text instead of executing — which matters as soon as your map
shows data you didn't author yourself (uploads, shared tables, third-party APIs). Values landing
in an href or src are additionally checked for a safe URL scheme.
If you need raw HTML stored inside the data itself to render as markup, that is an explicit opt-in and should only be used with data you control.
Labels
label= puts permanent text chips on features — a column name labels each feature from
its own value, anything else is the literal text:
m.add_circle_markers(sites, name="Sites", label="site")
m.add_polygon(zones, name="zone_id", label="zone_id")
Every geometry takes one: points anchor at the point, lines at their middle vertex, areas at their centre. Label text is escaped like popup values, and on a time layer the chips appear and vanish with their features. Labels are DOM elements — built for sites and zones, not point clouds — so the point builders warn past a thousand.
React
The same map as a component. swiftmap-core/react exports <SwiftMap>, one more host
over the core the notebook widget and the static export use — not a second renderer:
import { SwiftMap } from "swiftmap-core/react";
// plus leaflet.css, leaflet-geoman.css and swiftmap-core/swiftmap.css from your bundler
<SwiftMap ref={mapRef} layers={layers} buffers={buffers} center={[36.05, -5.25]} zoom={12}
showLegend timeConfig={{ period: "P1D" }}
onViewChange={...} onLayerToggle={...} onFeatureClick={...}
onDrawChange={...} onTimeChange={...} />
Props are the same configs the Python side builds (layers, binary buffers, the
legend/time/draw/scale/logo configs); the callbacks are the core's write-backs; and
mapRef.current.applyPatch(ops, buffers) is the widget's own incremental path — a live
feed's buffer_append/append ops land exactly as they do from Python. react,
leaflet, leaflet.glify and Leaflet-Geoman are peer dependencies your bundler owns.
examples/react/ is the working example, and tier 3 drives it.
Building layers in JS no longer means hand-writing configs -- createMapModel
is the same authoring surface Python has, held byte-identical to it by a
conformance suite:
import { createMapModel } from "swiftmap-core";
const model = createMapModel();
model.addCircleMarkers({ lat, lon, site, value }, { name: "Sites" });
model.addPolygon(ring, { name: "Zone", fillColor: "#00ff00" });
<SwiftMap {...model.props()} />
Same defaults, same folder seeding, same merge rule (same name + same folder
becomes one collection), same auto-fit -- because a golden-fixture suite pins the
JS output (state, buffers, and the op stream) byte-identical to what Python's
Map produces for the same inputs. The whole surface is there: data-driven
colour and size with their legend blocks (colorCol, radiusCol), WKT strings
and GeoJSON straight into the builders (addPolygon("POLYGON ((...))"), holes
and multi-parts included; several features fan into numbered layers),
addCollection, findLayers / select / hide / show / updateLayer
(attributes, replace, and the append delta), makeTimeLayer / configureTime,
and addBasemap over a catalogue generated from the same registry Python
resolves. Every mutation emits the ops Python's transport emits
(model.subscribe), so a live feed drives the map through
useSwiftMapFeed(model, mapRef) from swiftmap-core/react -- one state model,
wire cost of the batch -- while useSwiftMapModel covers the
mutate-then-snapshot shape. examples/react/ is authored entirely through the
model. Not in JS yet: labels, feature styles/highlight, imagery, and
column-driven point fan-out.
Hand-building configs and buffers anyway? The contract, which nothing else documents:
| buffer key | dtype | stride |
|---|---|---|
<id> |
Float64 | 2 per vertex, lat then lon (the reverse of GeoJSON/WKT order) |
<id>::colors |
Uint8 | 4 per point, RGBA |
<id>::radii |
Float32 | 1 per point, pixels |
<id>::times |
Float64 | 2 per feature, [start, end] epoch milliseconds (never seconds) |
Multi-ring polygons carry rings: [[5, 5], [5]] — parts → ring vertex counts,
concatenated into one flat buffer, first ring of each part the outer boundary and the
rest holes; a single plain ring omits the key. Multi-part lines carry parts (vertex
counts per part). Two layers added with the same name and layer_group merge
into one type: "group" entry with the members nested under layers — one sidebar
entry, selected as one unit — and the group has an id of its own. A map given layers
but no center/zoom opens fitted to their bounds, exactly as Map() does in Python.
Malformed configs no longer fail silently: the core warns on unknown types,
buffer/count mismatches, desynced property columns, seconds-shaped times, and append
ops whose base disagrees with the layer (collectLayerProblems exports the same
checks for linting up front).
Streamlit
The fourth stack, and a bidirectional one: st_swiftmap renders the map as a
Streamlit component and returns what the viewer did — clicks, draws, the viewport,
the time slider, layer toggles — on every rerun.
import streamlit as st
from swiftmap import Map
from swiftmap.streamlit import st_swiftmap
@st.cache_resource
def build():
m = Map(height="600px")
m.add_circle_markers(df, name="Sensors", color_col="reading")
return m
events = st_swiftmap(build(), key="map")
if events["clicked_layer_id"]:
st.write(events["clicked_layer_id"], events["selected_index"])
aoi = events["drawings"]
Build the map once — @st.cache_resource, or st.session_state when each session
mutates its own — and let the component decide: every call carries a change
fingerprint, so a rerun that changed nothing costs the frontend a comparison, not a
rebuild. The returned dict always has the same keys, empty before anyone has
interacted. The component ships Leaflet, glify and Geoman bundled inside the wheel, like the widget
and the export do — no stack needs a network. pip install swiftmap[streamlit];
st.components.v1.html(m.to_html()) remains the static, no-interaction embed.
examples/streamlit/app.py is the working app.
Sharing a map: one static file
m.save("map.html") # or: html = m.to_html()
The export is one self-contained HTML file with the map's whole state baked in — layer configs, every coordinate/time/style buffer (base64, so expect roughly 4/3 of their binary size), the widget bundle and its CSS. Hand the file to anyone: it opens from disk or a static file host with no Python, no kernel, no server. The sidebar and time playback work fully client-side, and the automatic fit rides along so the file opens framed on its data. It is genuinely self-contained: Leaflet, glify and Geoman are inside the file too, so it opens on a machine that has never had a network.
to_html() returns the same document as a string, which is also the Streamlit story:
import streamlit.components.v1 as components
components.html(m.to_html(), height=600)
Interaction in an export stays in the file — there is no Python behind it to sync back to.
Performance notes
The things that keep large maps fast are on by default and mostly invisible:
- Coordinates — points, lines, and polygons alike — travel as binary float64 buffers, never as JSON in the layer configs. Data-driven colors and radii ride the same transport.
- Every mutation after the first render is a small patch, not a resend of the map. A sidebar toggle costs ~100 bytes at any scale.
- Time filtering for points runs on the GPU (per-point times upload once as vertex attributes; the tick is a uniform).
- Every
add_*call already batches internally. Group several calls into one message withwith m.batch(): ...— it is reentrant, so nesting is safe.
sync() pushes a render when auto_sync=False; resync() replaces the client's entire
state, useful if a view may have missed updates.
Full parameter documentation lives in each method's docstring
(e.g. help(Map.add_markers)).
Contributing
Want to add support for a new data source, fix a bug, or improve the docs? See
CONTRIBUTING.md. The JS lives in src/ and must be rebuilt after
changes (npm run build); the committed bundle is what ships.
Development Notes
Claude Code is used during development of this project — for refactoring, building test harnesses, and working through architecture decisions. Direction, review, and acceptance are human-owned: changes are verified against the test suite and exercised in a real application before they land.
Metadata
Release files for swiftmap 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| swiftmap-0.4.0.tar.gz | 691.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| swiftmap-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.3 MB
Release files / swiftmap-0.4.0.tar.gz
| Download URL | swiftmap-0.4.0.tar.gz |
|---|---|
| Size | 691.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
24c8e461962f80cbb8e1f6f001978b404e5c0a7cd28ae51d7b484468d4ea864e
|
|
BLAKE2b-256 checksum How to use checksums |
f71f998c8a493fe90d8ad1f8f66866347674e0802d31c3abec43e81e985f6cf5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / swiftmap-0.4.0-py3-none-any.whl
| Download URL | swiftmap-0.4.0-py3-none-any.whl |
|---|---|
| Size | 655.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cfd497cc18f4d30ac1f4013c262635752c4cb1db0f05bc0319cd55ab7c884a0f
|
|
BLAKE2b-256 checksum How to use checksums |
31d789f776410354edfa43351afae3ee1dbce09a590c539f7fbf450abac01a20
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|