Skip to content

Embedding maps in a website

roadstyle can output a stack-agnostic JSON spec (to_spec) plus ready-to-use HTML, so you can put a styled road map into any web page — from "drop in an iframe, write zero code" to "feed the JSON to my own Leaflet / MapLibre frontend".

What the outputs are

Call Returns Use when
to_spec(gdf, ...) dict (JSON) you (or a frontend dev) will render it yourself
to_geojson(gdf, ...) dict (FeatureCollection) you only need the styled GeoJSON
to_html(gdf, full=True) str (full page) you want a complete .html page
to_html(gdf, full=False) str (<div>+<script>) you want to inject a map into an existing page
to_iframe(gdf) str (<iframe srcdoc=…>) easiest — no front-end code at all
save(gdf, "map.html", ...) writes a file a standalone interactive map file
save_spec(gdf, "map.json", ...) writes a file the JSON for a frontend / API

All of them take the same styling arguments as render_edges (color_by, cmap, colors, width_by, …).

The canonical JSON spec

import roadstyle as rs
spec = rs.to_spec(edges, color_by="aadt", cmap="viridis", basemap="dark_matter")
rs.save_spec(edges, "roads.json", color_by="aadt", cmap="viridis")
{
  "roadstyle": "spec/1",
  "crs": "EPSG:4326",
  "bounds": [[minLat, minLon], [maxLat, maxLon]],
  "render": { "sandwich": true, "line_cap": "round", "line_join": "round" },
  "basemap": { "key": "dark_matter", "url": "...", "attr": "...", "is_dark": true },
  "basemaps": [ /* the same shape — base maps offered to the in-map switcher */ ],
  "tooltip": ["name", "aadt"],
  "legend": { "kind": "continuous", "title": "aadt", "vmin": 0, "vmax": 25000, "ramp": ["#440154", ...] },
  "color_options": [ /* present only with color_options= : one entry per "colour by" option */
    { "name": "Class", "prop": "__rs_fill",    "legend": null },
    { "name": "AADT",  "prop": "__rs_fill__1", "legend": { "kind": "continuous", ... } } ],
  "color_active": 0,
  "geojson": { "type": "FeatureCollection", "features": [ /* each feature.properties carries: */ ] }
}

Each feature's properties carry the baked-in resolved style — your frontend just reads them, it doesn't need roadstyle's logic:

property meaning
__rs_fill fill (centre-line) colour
__rs_w fill width (px)
__rs_op fill opacity
__rs_dash dash pattern (or null)
__rs_casing resolved casing colour (or null; one per edge, constant on every base map) — what every backend draws
__rs_cw casing width (px)
__rs_cop casing opacity
__rs_class road class / category
__rs_fill__1, __rs_fill__2, … extra fill colours, one per color_options entry (the renderer swaps which one the fill reads)

Render order = the geometry sandwich. Draw a casing layer first (using __rs_casing/__rs_cw), then the fill layer on top (__rs_fill/__rs_w). That keeps road borders from slicing through higher roads.


Option 1 — iframe (no front-end code)

The simplest path. to_iframe returns a self-contained <iframe> you paste anywhere.

html = rs.to_iframe(edges, color_by="aadt", cmap="viridis", height="600px")
# paste `html` into your page, or write it to a template

Or save a standalone file and point an <iframe> at it:

rs.save(edges, "roads.html", color_by="aadt", cmap="viridis")
<iframe src="roads.html" style="width:100%;height:600px;border:0;"></iframe>

Option 2 — Leaflet (your own map)

Serve the spec JSON, then style each feature from its __rs_* props:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<div id="map" style="height:600px"></div>
<script>
fetch("roads.json").then(r => r.json()).then(spec => {
  const map = L.map("map");
  const bm = spec.basemap;
  L.tileLayer(bm.url, {attribution: bm.attr, subdomains: bm.subdomains, maxZoom: 20}).addTo(map);

  // casing under …
  L.geoJSON(spec.geojson, {style: f => {
    const p = f.properties;
    return p.__rs_casing && p.__rs_cw
      ? {color: p.__rs_casing, weight: p.__rs_cw, opacity: p.__rs_cop, lineCap:"round", lineJoin:"round"}
      : {opacity: 0, weight: 0};
  }}).addTo(map);

  // … fill over (the geometry sandwich)
  L.geoJSON(spec.geojson, {style: f => {
    const p = f.properties;
    return {color: p.__rs_fill, weight: p.__rs_w, opacity: p.__rs_op,
            dashArray: p.__rs_dash, lineCap:"round", lineJoin:"round"};
  }}).addTo(map);

  map.fitBounds(spec.bounds);
});
</script>

Option 3 — MapLibre GL (vector / WebGL)

Add the spec's GeoJSON as a source and two line layers reading the __rs_* props via ["get", ...] expressions:

<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css"/>
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<div id="map" style="height:600px"></div>
<script>
fetch("roads.json").then(r => r.json()).then(spec => {
  const bm = spec.basemap;
  const map = new maplibregl.Map({
    container: "map",
    style: {                       // a minimal raster basemap from the spec
      version: 8,
      sources: { bg: { type: "raster", tiles: [bm.url.replace("{s}", "a")], tileSize: 256, attribution: bm.attr } },
      layers: [{ id: "bg", type: "raster", source: "bg" }]
    }
  });
  map.on("load", () => {
    map.addSource("roads", { type: "geojson", data: spec.geojson });
    map.addLayer({ id: "casing", type: "line", source: "roads",
      paint: { "line-color": ["get","__rs_casing"], "line-width": ["get","__rs_cw"],
               "line-opacity": ["get","__rs_cop"] },
      layout: { "line-cap": "round", "line-join": "round" } });
    map.addLayer({ id: "fill", type: "line", source: "roads",
      paint: { "line-color": ["get","__rs_fill"], "line-width": ["get","__rs_w"],
               "line-opacity": ["get","__rs_op"] },
      layout: { "line-cap": "round", "line-join": "round" } });
    map.fitBounds([spec.bounds[0].slice().reverse(), spec.bounds[1].slice().reverse()]);
  });
});
</script>

MapLibre wants [lon, lat]; the spec's bounds are [lat, lon] (Leaflet order), hence the .reverse(). A future roadstyle option may emit a zoom→width curve as a MapLibre ["interpolate", ["linear"], ["zoom"], …] expression for line-width (see parameters — widths are fixed pixels today).


Which option for you?

  • No front-end experience / just need it on a pageOption 1 (iframe).
  • You already have a Leaflet mapOption 2.
  • You use vector tiles / want GPU rendering / lots of dataOption 3 (MapLibre).
  • A React/Vue app → fetch the spec and feed spec.geojson to your map component using the same __rs_* accessors shown above.

Reacting to a selection (click → your code)

roadstyle.js hands clicks back to your page so a custom UI can react. Register handlers in JavaScript (an interaction_config.json can't carry functions):

const m = new RoadStyleMap("map");
m.on("select", (feature, layer) => {
  // feature.properties carries __rs_class plus your original data columns
  console.log("selected", feature.properties);
});
m.on("deselect", (prevFeature) => console.log("cleared"));
await m.load("map_data.json");

// or poll instead of using callbacks:
const current = m.getSelection();   // the selected feature, or null

Selection is single — clicking another road replaces it; click the same road again, or click the map background, to deselect (each fires onDeselect). You can also pass the handlers up front: new RoadStyleMap("map", { onSelect, onDeselect }).

The RoadStyleMap JS API (events, recolour, custom panels)

roadstyle.js is more than a renderer — it's a small API your page can drive. The built-in widgets are opt-in via widgets, but you can also build your own UI on top.

Event bus. on(event, fn) / off(event, fn) subscribe to map events; several listeners per event are fine (the legacy onSelect/onDeselect options still fire alongside them):

event fired with when
ready (map, spec) after the map has loaded and rendered
select / deselect (feature, layer) / (prevFeature) a road is selected / the selection cleared
colorchange (option, index) the active "colour by" option changed

Switchable colouring. Bake the options in Python with color_options (works on to_spec / save / to_html too), then drive them client-side:

m.getColorOptions();        // [{name, prop, legend}, …] baked into the spec
m.setColorField("AADT");    // recolour by name or index — no re-render; swaps the legend
m.getColorField();          // the active option's index

Turn the built-in picker on with widgets: { colors: true }, or build your own and call setColorField.

Custom panels. addPanel gives you styled, auto-stacking chrome in any corner; fill it and wire it to the API:

const m = RoadStyle.create("map", spec, { widgets: { colors: false } });
m.on("ready", map => {
  map.addPanel({ title: "Colour by", position: "topright", render(body, map) {
    map.getColorOptions().forEach((o, i) => {
      const b = document.createElement("button");
      b.textContent = o.name; b.onclick = () => map.setColorField(i);
      body.appendChild(b);
    });
    map.on("colorchange", (opt, idx) => { /* keep your buttons in sync */ });
  }});
});

addPanel({ position, title, collapsible, render(body, map) }) returns { el, body, remove() }. A worked page is in examples/recolor_custom_panel.py.

On the web (MapLibre) backend the page is a rendered map, not a RoadStyleMap — it has its own, larger JS surface: window.rs* setters for every control (rsSetBasemap, rsSetClasses, rsSetColorField, rsSetOverlay, rsSetView3D, rsSelect/rsDeselect), the id-set query verbs (rsQueryrsFilter/rsColor/rsHighlight/rsGetProps/rsFocus), and rs:* CustomEvents. See the web-backend JS API and the copyable ui/ templates.