Skip to content

MapLibre web backend (backend="web")

The web backend renders a self-contained, interactive MapLibre (vector) map — the same "geometry sandwich" cartography as the folium/lonboard backends, but drawn with MapLibre's native zoom expressions so it matches the look of openstreetmap-carto. It is the default backend, so you get it without passing backend=.

import roadstyle as rs

edges = gpd.read_file("edges.gpkg")            # needs a `highway` column (any CRS)
rs.render_edges(edges, basemap="dark_matter").save("roads.html")            # web is the default
rs.render_edges(edges, backend="web", basemap="dark_matter").save("r.html") # explicit, same thing

render_edges(..., backend="web") returns a WebMap with .save(path) (and inline display in a notebook). The saved file is one HTML document with MapLibre and the road data bundled inside it — it opens with no web server and no internet (see Offline).

From the command line this backend is -f web (the default): roadstyle edges.gpkg -o map.html.

Not the same as the roadstyle.js page. rs.save(...) / the CLI's -f rsjs write the roadstyle.js spec page (a __rs_*-baked page for embedding — see Frontend integration). This page is about the render backend render_edges(backend="web") / -f web, a finished MapLibre map, not a spec.

What it does that folium/lonboard don't

Feature What you get
Per-zoom widths Roads widen smoothly as you zoom in (the osm-carto width-by-zoom table), instead of a fixed pixel weight. Fixes the "fixed-pixel roads blob when zoomed out" limitation of the other backends.
Two-way directional lanes A two-way street's two directed edges fan into parallel lanes via a pixel-proportional line-offset, so you can see both directions. One-way edges stay centred.
Direction arrows One-way edges get chevrons placed along the line (native symbol-placement: line).
Street-name labels Names are placed along the road (curved), from the name column.
Hover / select Hovering highlights the road under the cursor; clicking selects it and shows its attributes; clicking the background deselects (restoring the original colour). Driven by MapLibre feature-state — a GPU recolour, no re-layout.
Base-layer switcher An in-map dropdown to switch the background (dark / light / voyager / satellite / OSM / a tile-less blank canvas).
Scale bar + zoom read-out A metric scale bar bottom-left with a live z 16.4 badge beside it.
Road z-order Higher-class roads draw over lower ones at junctions (a motorway over a residential), with _link ramps tucked just under their through road.
Tunnel / bridge grade separation Tunnels draw underneath (dashed + faded), bridges draw on top (heavier, square-capped casing) — see below.
Dynamic recolouring Bake several "colour by" options with color_options= and switch between them client-side from a Colour by dropdown — no re-render, no server. See below.
Overlay layers Bring your own geometry (zone polygons, POI circles, any lines) via overlays= — drawn under/over the roads, clickable, toggled from a Layers control. See below.
Boundary overlay An optional dashed outline (e.g. the clip area) drawn on top of the roads via boundary= — see below.

Grade separation (tunnels & bridges)

If your data carries tunnel, bridge, and/or layer columns, the backend orders grade-separated roads by elevation so a tunnel passing under a street no longer looks connected to it, and a bridge reads as a deck spanning what's below.

One baked integer per edge, lvl, drives everything:

  • bridge truthy → +1, tunnel truthy → -1, else a negative layer tag → -1, else 0. A positive layer alone is not a bridge — only the bridge column earns the bridge treatment (2D deck styling, 3D extrusions).
  • lvl feeds the draw order (lvl*1000 + class_rank in line-sort-key, so every tunnel is below every surface road and every bridge above), the layer an edge lands in, and its per-grade style:
  • tunnels — a dashed, butt-capped casing over a faded fill (reads as "underground");
  • bridges — a heavier (1.25×), butt-capped casing (reads as a deck).

Columns absent ⇒ every edge is treated as ground level (no change). Override the column names with tunnel_col / bridge_col / layer_col.

# DuckDB driving graph that carries grade tags
edges = rs.from_duckdb(
    con,
    "SELECT highway, name, oneway, layer, bridge, tunnel, ST_AsWKB(geometry) AS geom "
    "FROM driving.edges",
    geometry="geom", crs=4326,
)
rs.render_edges(edges, backend="web", basemap="dark_matter").save("city.html")

Parameters

All the shared render_edges arguments apply (palette, include/exclude, color_by/cmap/width_by, style, highway_col, basemap, basemaps, name). The backend adds:

Parameter Type Default Meaning
arrows bool True Show one-way direction chevrons along each one-way edge.
labels bool True Show curved street-name labels (from the name column).
filter_control bool True Show the collapsible road-class filter panel (a checkbox per class present; unchecking hides that class across every road layer).
filter_col str None Column the filter panel lists/filters by, when it should differ from the styling highway_col. Default None = filter by the styling column. Set it to filter by a source-native class while widths/casing follow a different (e.g. OSM-highway proxy) column — the panel reads this raw property directly, so no restyle is needed.
basemap_switcher bool True Show the in-map base-layer dropdown (its options come from basemap / basemaps). With False plus an explicit basemaps= list, the entries stay baked and addressable via window.rsSetBasemap for a custom UI; False alone bakes only the fixed backdrop.
road_popup bool True Info popup shown when a road is clicked (lists the feature's attributes). Click-to-select works either way; set False to drive your own readout from window.map events.
road_tooltip bool / list of cols False Hover tooltip: True = all attributes, a list of column names = only those, False = off. The shared tooltip= arg (folium backend / CLI --tooltip) is accepted as an alias — when given and road_tooltip is unset it drives this, so the same tooltip= / --tooltip call works on every backend. road_tooltip is the web-native name and wins if both are set.
offset_frac float 0.28 Two-way lane offset as a fraction of the road's pixel width (pixel-proportional ⇒ constant overlap at every zoom). 0 = no lane split.
width_frac float 0.6 Each two-way lane's width as a fraction of the full road width once the directions have fanned apart (a little over 0.5 so the two lanes overlap rather than leave a centre gap).
offset_zoom int 15 Zoom at which lanes start fanning apart / splitting (ramped in over ~2 zoom levels). Below this the two directions stay coincident.
tunnel_col str "tunnel" Column marking tunnels (used for lvl).
bridge_col str "bridge" Column marking bridges.
layer_col str "layer" OSM layer tag column (signed elevation when tunnel/bridge are absent).
boundary geometry / GeoDataFrame / GeoJSON / None None Optional outline drawn on top of the roads — see Boundary overlay.
color_options mapping / list / None None "Colour by" options baked for client-side recolouring + a dropdown — see Dynamic recolouring.
overlays list of Overlay / None None Extra layers the caller brings (zones / POIs / lines) — see Overlay layers.

From the CLI these map to --no-arrows / --no-labels / --no-filter / --no-basemap-switcher.

The JavaScript API (window.rs*)

Every in-map control is a thin UI over a global function, so custom pages can drive the map directly. Each setter updates the map, keeps the built-in control in sync (if present), and dispatches a CustomEvent on document:

function does event
rsSetBasemap(keyOrIdx) switch the base map (key, label, or index into RS_BASEMAPS) rs:basemapchange
rsSetClasses(list) show exactly these road classes, hide the rest rs:filterchange
rsSetColorField(nameOrIdx) switch the active color_options entry rs:colorchange
rsSetOverlay(labelOrIdx, on) show/hide one overlay rs:overlaychange
rsQuery(p => …, layer?) evaluate a predicate over every feature's columns → id set
rsFilter(ids, layer?) show only these features (null resets); on roads, ANDs with the class filter rs:filterchange
rsColor(ids, "#hex", layer?) paint the set one colour, layered over the base styling; or several sets at once — rsColor([[idsA, "#f80"], [idsB, "#08f"]]), earlier pairs winning overlaps (roads only). Reset: rsColor(null) / rsColor(null, null, label) rs:colorchange
rsHighlight(ids, layer?) selection glow on the set ([] clears) rs:highlightchange
rsGetProps(ids, layer?) the rows behind the ids, internal fields stripped — table-ready

layer is optional on all of these: omitted → the roads; an overlay's label (or index) → that overlay is the queried table. Each layer is its own id space — never mix ids across layers. | rsFocus(ids, opt?, layer?) | fly the camera to fit an id (or id set); opt merges into MapLibre's fitBounds (padding 80, maxZoom 17) | — | | rsSelect(id) / rsDeselect() | select one edge exactly like a click (glow + popup/panel; a 3D bridge glows as that edge's own deck ribbon) / clear | rs:select / rs:deselect | | rsSetView3D(on) | tilt to the settings' camera.pitch_3d / back to flat north-up | rs:viewchange |

The id sets use the roads source's generated feature ids — the same id space as rs:select events. Labels, arrows and 3D deck ribbons sit on merged helper sources, so rsFilter prunes road geometry but not those decorations (the class filter prunes everything).

Two sidebars built purely on this API ship with the package — one call each:

rs.render_dashboard(edges).save("dashboard.html")   # query box → results table → detail panel
rs.render_report(edges).save("report.html")          # KPI cards, by-class legend, search, read-out

Both render the map with the built-in controls off and inject the bundled sidebar, returning a WebMap; color_options={...} populates the Colour by picker and every render_edges keyword passes through. To reshape one, rs.sidebar_html("dashboard") (or "report") returns the HTML/CSS/JS fragment — edit it and re-inject before </body>. The same fragments live in the repo's ui/ folder (ui/dashboard/, ui/report/), each with a build.py, to fork wholesale.

Plus: window.map (the MapLibre Map — camera via map.easeTo({pitch, bearing, ...})), click selection events rs:select / rs:deselect, and the registries RS_BASEMAPS, RS_CLASSES (with RS_CLASS_COL — the column they came from — and RS_CLASS_COLORS — each class's baked fill colour), RS_COLOR_OPTIONS, RS_OVERLAYS for building your own controls. All of it works with the built-in controls hidden (basemap_switcher=False, filter_control=False, …).

Built-in search (panel mode)

In panel mode (popup_mode="panel", or road_popup="panel") the docked side panel carries a Search roads box. It is a case-insensitive substring search over each road's popup fields (or every public property when none are curated), skipping internal fields and empty / nan values — so it matches on any attribute, not just the name: a street name, a class, an edge_ref, or an id all resolve. Type ≥ 2 characters (debounced 250 ms, Esc clears); it lists up to 30 rows, each showing the road's name (or the matched value) with the matched field: value beneath. Clicking a row calls rsSelect + rsFocus — it selects the road exactly like a map click and flies the camera to it. It is built entirely on the public API (rsQueryrsSelect / rsFocus), so a custom sidebar can offer the same search (both ui/ templates do).

Dynamic recolouring (color_options)

Bake several pre-resolved "colour by" fill sets into the one map and switch between them in the browser — no re-render, no server round-trip. Pass color_options, an ordered mapping {name: {styler kwargs}} (or a list of {"name": ..., **kwargs}), and the backend adds a Colour by dropdown plus a legend that follows the active option. Each road keeps its width / casing / lanes; only the fill swaps (via setPaintProperty). A neutral base reads best, so pair the class option with palette="mono":

rs.render_edges(edges, backend="web", palette="mono",
    color_options={
        "Class": {},                                       # neutral mono base
        "AADT":  {"color_by": "aadt",  "cmap": "viridis"},
        "Speed": {"color_by": "speed_kph", "cmap": "magma"},
    },
).save("recolor.html")

Each option's value is the same data-driven kwargs you'd pass to render_edges (color_by, colors, cmap, vmin, vmax, width_by, palette, style); an empty {} is the class style on the base palette. The first option is active and drives the shared width/casing.

Categorical option — colour by a discrete field with an explicit colors={value: hex} map instead of a cmap (values not in the map fall back to the base fill). Mixing continuous and categorical options in one map is fine:

rs.render_edges(edges, backend="web", palette="mono",
    color_options={
        "Class": {},                                                            # neutral mono base
        "Cover": {"color_by": "cover", "cmap": "RdYlGn", "vmin": 0, "vmax": 1},  # continuous ramp
        "State": {"color_by": "state",                                          # categorical
                  "colors": {"good": "#1baf7a", "mid": "#eda100", "weak": "#e34948"}},
    },
).save("recolor.html")
  • cmap accepts any branca scheme name ("viridis", "YlOrRd", … — matched case-insensitively), any matplotlib colormap name including reversed "_r" variants (e.g. "RdYlGn_r"; the matplotlib fallback needs the numeric extra), or an explicit list of hex stops (["#000", "#fff"]). Set the numeric span with vmin / vmax.
  • Blank edges keep the base colour. Where a data option has no value for an edge (NaN / unmapped), that edge falls back to the base option's fill — so on a mono base, "no data" roads keep their neutral mono colour instead of a flat grey.
  • Drive it from your own UI. The page exposes window.RS_COLOR_OPTIONS (the baked options) and window.rsSetColorField(name|index) (recolour), and fires a rs:colorchange event on every switch — so a custom control can drive and follow the recolour. A worked custom-panel page is in examples/recolor_web_backend.py.

The same color_options work on the roadstyle.js spec page (to_spec / save / to_html), where the richer RoadStyleMap API exposes setColorField / getColorOptions and a colorchange event.

Overlay layers (overlays)

Bring extra geometry the caller owns — zone polygons, POI points, any lines — as a list of Overlay. Each becomes its own MapLibre source + layer(s), placed under or over the roads, clickable for a popup of the fields you list, and toggled from a Layers control. In panel mode (popup_mode="panel") an overlay click docks its read-out into the side panel exactly like a road's — titled with the overlay's label — instead of opening a floating popup. Overlays are passthrough data: your geometry with your style — they do not go through roadstyle's road-styling compiler.

rs.render_edges(edges, backend="web", palette="mono",
    overlays=[
        rs.Overlay(zones, kind="fill",   placement="under", color="#6aa9ff",
                   opacity=0.14, label="TAZ zones", popup=["taz_id", "name", "weight"]),
        rs.Overlay(pois,  kind="circle", placement="over",  color="#ff5d5d",
                   radius=7, label="POIs", popup=["name", "type"]),
    ],
).save("overlays.html")

kind is auto-detected from the geometry (polygon → fill+outline, point → circle, line → line) but can be forced. placement="under" draws beneath the roads (e.g. zone fills), "over" on top (e.g. POIs). Setting popup makes the overlay clickable (those fields show in the popup; an empty list = decoration only). Click precedence follows the visual stacking: an "over" overlay in front of the roads wins the click; then the roads; an "under" overlay only where the click misses every road — so a road inside a clickable zone stays selectable. A selected road also reports the interactive overlays under the click: its popup/panel appends a section per overlay, and rs:select carries them as event.detail.overlays ([{label, fields, properties}]) so a sidebar can show the road and its zone together. See Overlay for every field.

Overlays are single-colour. An Overlay paints every feature with its one color — there is no per-feature color_by / cmap for overlay data. To colour features by a value, put that data on the road edges and use color_options; overlays are for bring-your-own geometry drawn in a flat colour. (For data-driven points/lines that aren't road edges, split them into one Overlay per colour bucket.)

Boundary overlay

Pass boundary= to trace an area outline on top of the roads — typically the polygon the network was clipped to, so the map shows its extent. It renders as a dashed violet line layer (id boundary), separate from the road layers, so it is excluded from the road-class filter panel and from hover/click picking.

It accepts whatever you have on hand and normalises it to a GeoJSON FeatureCollection in EPSG:4326: a shapely geometry, a GeoSeries/GeoDataFrame (reprojected for you), or a GeoJSON mapping (a geometry, Feature, or FeatureCollection — assumed already lon/lat). A polygon's rings are stroked as lines. None (the default) draws nothing.

from shapely.geometry import box

clip = box(18.04, 59.30, 18.10, 59.33)         # or a GeoDataFrame / GeoJSON dict
rs.render_edges(edges, backend="web", boundary=clip).save("clipped.html")

Offline / self-contained

The saved HTML bundles MapLibre's JS+CSS inline and inlines the road GeoJSON, so:

  • it opens straight from disk (file://) with no web server and no internet — the road network always renders;
  • the only online bits are the base-map raster tiles and label glyphs; offline those simply don't draw (blank background, no labels), but the roads still do.

This is what makes the output a true single-file deliverable. (The folium/lonboard backends and the roadstyle.js spec page have their own delivery models.)

Vector tiles in the file (tiles=True)

For big networks, tiles=True swaps the inline road GeoJSON for a PMTiles vector tileset embedded in the same HTML file (base64 + an in-memory pmtiles:// protocol — the vendored pmtiles.js). Still one file, still opens from disk; what changes is how the browser consumes the roads:

  • MapLibre parses only the tiles in view instead of every feature at load — a ~100k-edge map boots in a couple of seconds instead of ~10, and stays responsive when you pan/zoom;
  • low zooms carry simplified geometry (and sub-pixel pieces drop out naturally), so zoomed-out views skip work you can't see. Class thinning follows the minzoom parameter, exactly like the inline version: by default every class is in every tile (residential shows at the opening view); pass minzoom=True (or a dict) to also thin minor classes out of the low-zoom tiles;
  • the street-name/arrow annotation slots ride along as a second tile layer — no giant inline symbol source.

Everything else is unchanged: the full JS API (rsQuery, rsFilter, rsColor, rsSelect, popups, rsFocus) works identically — full per-edge attributes travel in a small gzipped sidecar table the page inflates on load, and feature ids are the same index id space as the inline version. Needs the tiles extra (pip install "roadstyle[tiles]"); on the CLI it's --tiles. Tileset knobs (zoom range, extent, clip buffer) live in settings under config.tiles.

Trade-offs: the file is somewhat larger than the gzip-inlined equivalent (geometry is duplicated across zoom levels) and the Python build takes longer (roughly a minute at ~100k edges) — the win is entirely on the viewer's side. Below ~10⁴ edges the default inline mode is simpler and just as fast.

When to use which backend

Short version: start with web; folium for folium-ecosystem workflows, lonboard for very large edge sets, to_spec/roadstyle.js when your own web app draws the map. The full decision guide — feature matrix, data-size guidance, and the web-vs-spec dashboard question — is Choosing an engine.

Camera & 3D view

The starting camera is a setting (camera block: pitch, bearing, pitch_3d) with per-call overrides:

rs.render_edges(edges, pitch=55, bearing=-25)     # open tilted/rotated
rs.render_edges(edges, view_3d=True)              # tilted + extruded bridge decks

view_3d=True renders bridges as ramped 3D decks (fill-extrusion): connected bridge edges are walked into one structure only to shape the ramps (rising from the ground over ramp_m, forks staying at full height), but every deck ribbon belongs to a single directed edge — a two-way bridge splits into two side-by-side half ribbons, one per direction — so hover, click and rsSelect light one edge, never the whole structure. Ribbon width follows the road width model at bridge_decks.match_zoom, trimmed by bridge_decks.width_scale (default 0.6 — at a tilted camera the extruded side walls add apparent width, so a 1:1 ribbon reads too fat). A fixed polygon can't track the stylized px road widths across zooms, so decks read slightly narrow when zoomed far out — tune width_scale / match_zoom in settings if your typical viewing zoom differs. Each deck also carries a black casing: side strips in bridge_casing_color, casing_px (default 2) wide, topping out just below the deck surface — the 3D equivalent of the 2D bridges' line casing (bridge_decks.casing_px: 0 disables). Below bridge_decks.flat_below (default 16) bridges render as the classic flat cased lines instead — full stylized width, matching the roads — and the decks take over from that zoom up; selection glows on whichever representation is showing. Decks are semi-transparent (bridge_decks.opacity), hover/select as ONE structure, and show the same popup fields as flat edges. Every map also carries an on-map 2D/3D toggle button and a pitch-aware compass; no terrain/elevation data is used — the deck height is cartographic.

Street names & arrows (annotation slots)

Each road chain is divided into equal annotations.slot_m-metre slots: street names take the even slots, one-way arrows the odd ones — alternating along the road, never stacked. Unnamed roads leave their name slots empty. Text/icon zoom ramps plus MapLibre's collision culling handle density automatically; label/arrow colours live in the labels / arrows settings blocks.

Roads vs. overlays — the architecture

The road layer is the subject; overlays are annotations. Both take their default styling from the settings file, but they are entirely different machinery:

Roads (main layer) Overlays
Styling the full cartographic engine: palettes per class, zoom-scaled width curves, casing sandwich, draw-order ranks one flat paint each — a single colour, opacity, width/radius
Structure grade separation (tunnels faded below, bridges on top, 3D decks), two-way lane fanning, junction-sealed caps none — a fill, a line, or circles
Annotations street-name labels + one-way arrows in alternating slots none
Data-driven colour color_by ramps, color_table, the Colour by dropdown with legends constant colour per overlay
Interaction hover highlight, whole-bridge selection, popup/panel, rs:select events, class filter panel hover tint + popup, visibility checkbox in the Layers toggle
Settings footprint most of defaults.json (palettes, roads model, labels, arrows, slots, decks, camera) the small overlays defaults block
Data contract highway + the optional columns (see the README data contract) any geometry; properties feed the popup only

An overlay never grows casings or labels — by design, so the roads stay visually dominant and overlays read as context. If a second linear network deserves road-grade treatment (e.g. NVDB next to OSM), don't overlay it: render it as the main layer with a custom class vocabulary (highway_col= + a registered palette).