roadstyle as a Rust library: the design and the migration plan¶
Status: agreed 2026-10-23: decisions 1 to 8 as recommended, decision 9 open. Built (2026-10-23, branch rust-engine): P0, P1, P2 (the
crates), P4's style features (all of render's keywords that change the style, the full look too), P5 (the binding, the wheel, the whole test
suite with ROADSTYLE_ENGINE=rust), the output side of P3's contract (roadstyle:contract, roadstyle:state, roadstyle:controls,
docs/reference/contract.md) and of P6 the page writer, the PMTiles writer, export_look and the runtime API (rs). Release 0.25.0 is prepared (branch release-0.25.0, not published): the Rust engine as an option, with wheels for five platforms. Open: P3's typed look
and the layer roles, P6's npm packages and Tiler.restyle, the level options of compute_levels in Rust, P7. It continues One Rust engine for roadstyle
(steps 1 and 3 are done) and uses The style in a config file and
A general core: roads, items, and views. The decisions are in
Decisions.
Summary¶
roadstyle has one reference implementation, in Python (version 0.24.3, about 11,000 lines of Python, a 1,688-line page template and
about 500 tests). A Rust crate, roadstyle-rs (5,582 lines), already makes the style and the vector tiles of the simple mode, and it
has the level solver. duckosm-rust's browser page and the GeoLibre plugin use it through WebAssembly. The rest of roadstyle is only in
Python.
This plan makes one Rust library with a pure core and adapters around the core. The core has three independent parts: the road
model, the look (the typed config) and the engine. The adapters read and write files and connect the core to Rust, JavaScript and
Python. A typed render model (the drawing numbers of each road, the objects attached to roads and one order for the whole map)
replaces the __rs_* properties that Python and Rust bake today. The plan has seven phases. Python roadstyle stays the reference until the last phase: a change in the Rust output is
correct only when it is byte for byte equal to the output of Python.
Contents¶
- What exists today
- The architecture
- The crates
- The data flow of one map
- The render model: the numbers of each road, the objects on roads, the order of the map
- Input and output formats
- The interaction contract: what a dashboard reads and changes at run time
- The config: the look
- The public API
- Efficiency
- The migration plan
- The parity checklist
- Quality
- Decisions
- Found during the inventory
What exists today¶
Python roadstyle (version 0.24.3)¶
Line counts from wc -l at commit 337cf37.
| Part | Files | Lines | What it does | Heavy dependencies |
|---|---|---|---|---|
| Web renderer | render_web.py |
3,837 | render(...), 76 parameters: bakes the __rs_* properties, builds the MapLibre style, fills the page template |
geopandas, shapely |
| Page template | static/web_template.html, popup.js, popup.css |
1,688 + 30 | The page: controls, legend, popups, Street View, Google 3D, the 29 window.rs* functions and 13 rs:* events |
MapLibre 5.24.0 (vendored) |
| Entry point | render.py |
184 | render_edges(...), 21 parameters: data styler, filters, settings scope, dispatch to web, folium or lonboard |
|
| Stylers | stylers.py, style.py, colors.py, legend.py |
482 + 99 + 120 + 110 | Data-driven color and width (ClassStyler, CategoricalStyler, NumericStyler, ColorTableStyler), color ramps, folium legends |
branca, matplotlib, mapclassify |
| Config | _settings.py, config.py, palettes.py, basemaps.py, data/ |
343 + 113 + 173 + 315 | Palettes (4) and profiles (2) in YAML, the merge of user files and settings=, base-map providers |
pyyaml |
| Levels | levels.py, level_area.py, levels_store.py, level_editor.py |
816 + 320 + 197 + 506 | The drawing-order solver, the level-area folder workflow, DuckDB storage, the editor server | scipy (HiGHS), ortools, shapely |
| Export | export.py, tiles.py, emit.py, fastjson.py |
290 + 213 + 249 + 98 | export_style (style.json + XYZ or PMTiles), export_inline, export_look, the embedded PMTiles, the Leaflet spec |
mapbox-vector-tile, pmtiles, duckdb |
| Pages | pages.py, templates/*.html |
114 + 615 | render_dashboard, render_report, render_street_view: sidebars on the rs* API |
|
| Other backends | render_folium.py, render_lonboard.py, controls.py, interactive.py |
124 + 147 + 126 + 191 | folium and lonboard maps | folium, lonboard |
| Data model | edges.py, validate.py, filters.py, overlays.py |
370 + 62 + 54 + 115 | RoadEdges, readers (file, GeoJSON, Arrow, DuckDB), validation, filters, Overlay |
geopandas |
| Tools | cli.py, mcp_server.py, snapshot.py, studio/ |
223 + 194 + 106 + 533 | The roadstyle and roadstyle-levels programs, the MCP server, PNG screenshots, the Streamlit studio |
playwright, streamlit, osmnx |
The tests: 26 files, about 500 test functions (213 in tests/test_render_web.py). They are behavior tests on dicts, HTML and JS.
There is no golden test against Rust in tests/.
Rust today (rust/, commit 337cf37)¶
| Crate | Module | Lines | Content |
|---|---|---|---|
roadstyle-rs |
lib.rs |
185 | make_parts, make_style (the style as one JSON text), make_tiled (the style and its tiles), MAX_ROADS = 200,000 |
read, geom |
113 + 220 | GeoParquet (WKB lines) to features, WKB, area and centroid | |
roads, simple, slots |
378 + 317 + 466 | The __rs_* properties, the casing pieces, the names and the one-way arrows |
|
features, overlays |
111 + 258 | The base-map layers and edge_attach |
|
layers |
987 | The road layers and the expression builders (Model::from_look) |
|
props |
223 | The typed sources roads and simple (one copy of each key name) |
|
tiles |
593 | The vector tiles on request (MVT 2.1) | |
planner |
146 | metadata["roadstyle:planner"] |
|
levels/ |
1,484 | compute: the tags rule and the solver (least span first, then push-relabel min-cost flow) |
|
main.rs |
101 | The native program (timings) | |
roadstyle-wasm |
lib.rs |
102 | makeStyle, Tiler (style, tile, props), memoryBytes, the allocator that grows in large steps |
js/ |
148 | roads_sql.js, features_sql.js: the SQL that duckdb-wasm runs to write the inputs |
Facts that the plan must use:
- The engine reads one input file,
look.json(63 KB). Python writes it (tools/make_spike_fixture.pyof geolibre-roadstyle, mapstyle'srender_map(_export=...)). It holds Python's internal tables:settings,args,theme,config,styles,roads_model,slots,features,interaction. It has no version and no schema. - The scope of the engine is the simple mode with stored levels (
layers.rs:506): no 3D decks, no metre widths, no items, no full mode (simple=False). - Errors are
Result<_, String>(about 25 places). There are no features, no threads and no benchmarks. - The golden tests compare SHA-256 hashes with the output of geolibre-roadstyle's port (commit
0806ac2, with building rule C). That port was compared with Python's output in geolibre-roadstyle (spike/test_compare.py). This repository has no golden test from Python yet. The branchrust-colorsadds the first one (tests/make_goldens.py, not committed). - No CI job runs
cargo.
Work in progress (the plan includes it and does not do it again)¶
| Work | Where | Content | State on 2026-10-23 |
|---|---|---|---|
| Task 1: data-driven color and width, the legend | main checkout, branch rust-colors (session roadstyle-rs) |
tests/colors.rs against Python's export_inline for color_by, colors, cmap, vmin, vmax, width_by, color_table, color_options, color_active, legend; cmaps.json (the branca and matplotlib ramps) |
not committed; no Rust module yet |
| Task 2: one shared dashboard JS module | branch claude/dashboard-js-5ddf3e (session roadstyle-dashboard-js) |
20 ES modules in src/roadstyle/static/dashboard/, entry rsDashboard(map, options); the template loses 1,650 lines |
not committed |
| Task 3: the look resolution in Rust | after task 1, same session | Rust makes the look from the palette, the profile and the settings | not started |
Tiles follow tiles.py (step 2) |
branch rust-tiles, 89d8bff |
One roads source with source layers, zooms 6 to 15, buffer 80, simplification below the last zoom |
not merged |
| Tile speed | branch rust-tile-speed, 17f51c1 |
The bytes of a value are made once | being merged; conflicts with rust-tiles in tiles.rs |
The users¶
| User | Uses | How | Pinned at |
|---|---|---|---|
duckosm-rust (crates/duckosm-core) |
roadstyle-rs::levels only |
Cargo git dependency | 900b43e |
| duckosm-rust browser page | roadstyle-wasm: Tiler, memoryBytes |
build.sh builds the module; map_worker.js; a copy of look.json and of the two SQL files |
900b43e |
| GeoLibre plugin (geolibre-roadstyle) | roadstyle-wasm: makeStyle |
build.py builds the module (branch use-roadstyle-rs); at build time it runs Python roadstyle and mapstyle to make 64 looks |
900b43e |
| mapstyle | Python: render_edges, render_report, Overlay, export_style, export_inline, load_area_levels, palette_to_dict |
Python import | roadstyle>=0.24.2 |
| Python users (16 projects) | render_edges (75 calls), Overlay (82 calls), render_street_view (1), render_dashboard (1) |
Python import; 45 calls use backend="web", 4 use folium, 1 lonboard |
various |
| Page scripts of users | rsColor, rsHighlight, rsFocus, rsSelect, rsSetColorField, rsSetOverlay, rs:select |
JS in the page |
About 27 of the keywords carry most of the use: backend, name, tooltip, basemap, palette, basemaps, boundary,
arrows, overlays, filter_control, color_options, road_popup, color_by, cmap, vmin, vmax, legend, colors,
color_active, road_tooltip, street_view, street_view_key, google_3d, color_table, labels, hover_color, select_color.
The architecture¶
The choice: a pure core with adapters around it¶
The core has no I/O. It has three parts, and two of them are independent inputs of the third:
- The road model (
roadstyle-model): the roads as columns, their geometry, the column map, the validation. - The look (
roadstyle-look): the typed, versioned config: palettes, profiles, settings and the map options that change the style. - The engine (
roadstyle-engine,roadstyle-tiles,roadstyle-levels): pure functions from (roads, look) to the style, the tiles and the levels.
The adapters are around the core. Each one connects the core to one thing outside:
- Readers and writers (
roadstyle-io): GeoParquet, Arrow, GeoJSON, DuckDB, the style file, the tile folder, PMTiles, the HTML page. - Bindings: WebAssembly and JavaScript (
roadstyle-wasm), Python (roadstyle-py). - The dashboard module (JavaScript): a separate front-end package. It reads only the style and its
roadstyle:*metadata. It does not call the engine.
flowchart LR
subgraph adapters_in["Adapters: input"]
PQ["GeoParquet / Arrow"]
GJ["GeoJSON"]
DDB["DuckDB (native)"]
GDF["GeoDataFrame (Python)"]
CFG["palette, profile, settings files"]
end
subgraph core["Pure core: no I/O, builds for wasm32"]
M["roadstyle-model<br/>roads as columns, render model"]
L["roadstyle-look<br/>typed, versioned look"]
E["roadstyle-engine<br/>style, data-driven paint, legend"]
T["roadstyle-tiles<br/>vector tiles"]
LV["roadstyle-levels<br/>drawing order"]
M --> E
L --> E
M --> T
E --> T
LV --> E
end
subgraph adapters_out["Adapters: output and bindings"]
IO["roadstyle-io<br/>style.json, tiles, PMTiles, page"]
WASM["roadstyle-wasm<br/>JS API"]
PY["roadstyle-py<br/>Python API"]
end
DASH["dashboard module (JS)<br/>reads style + metadata"]
PQ & GJ & DDB --> M
GDF --> PY
CFG --> L
E --> IO
T --> IO
E & T & LV --> WASM
E & T & LV --> PY
WASM -.->|"style JSON"| DASH
IO -.->|"page"| DASH
The core in the middle has no arrow to a file, a database or a browser. The model and the look do not point at each other.
Rules of the layers¶
- A dependency points into the core, never out of it.
roadstyle-enginedoes not know Parquet, Python or JavaScript. roadstyle-modelandroadstyle-lookdo not depend on each other. Data-driven color needs both the values of a column and the look, so it is a function of the engine, not of the look.- The core builds for
wasm32-unknown-unknownwith no features. A CI job checks this (see Quality). - The engine is single-thread by default. The feature
paralleladds rayon for the native build. - The page options (popups, Street View, controls) do not go into the engine. The engine copies the few that the style must
carry into its metadata (
roadstyle:interaction). The other options go to the dashboard module. - One copy of each rule. The worker SQL, the palettes and the profiles each have one file, and each user takes them from one commit of this repository.
The alternatives that I did not choose¶
| Alternative | Why not |
|---|---|
| A strict stack: data, then config, then engine, then interface (each layer uses only the layer under it) | The config does not need the data, and the data does not need the config. A stack makes one depend on the other with no reason. It also puts the readers (Parquet, DuckDB) under the engine, so the WebAssembly build carries them. |
One crate with modules and feature flags (today's roadstyle-rs) |
It works for one user. With three users it has costs: duckosm-core takes Parquet and Arrow to get only the level solver; a change in one module rebuilds all; the public API is every pub item. The crate split is cheap now (5,600 lines) and expensive later. |
Many small crates (one per module: slots, simple, overlays, ...) |
These modules change together for one style change. More crates give more version bumps and more Cargo.toml files, and no user needs them alone. |
| The engine builds the page (HTML) | The page is interaction, not style. Three pages exist (the Python page, the duckosm-rust page, the GeoLibre plugin) and each has its own frame. The dashboard module serves all three. The page writer is a small adapter in roadstyle-io. |
Keep look.json as Python's internal tables |
Each Python refactor can break the Rust users, and no test shows it. A typed, versioned look with a schema stops this. |
| A full rewrite of Python roadstyle in Rust at once | No reference during the work, and 16 Python projects wait. Part by part, with Python as the reference, each part can ship alone. |
What stays in Python¶
| Stays in Python | Why |
|---|---|
The folium and lonboard backends (render_folium.py, render_lonboard.py, controls.py, interactive.py, legend.py) |
They are Python objects for notebooks. They can take the resolved colors and widths from Rust (one call for each distinct class key, see vectorize_style_resolution.md). |
The GeoDataFrame front-end: render_edges and its keywords, RoadEdges, Overlay |
The Python API does not change. It converts the GeoDataFrame to Arrow and calls Rust. |
WebMap (.html, .save, _repr_html_), snapshot, the Streamlit studio, the MCP server, the notebook helpers |
They are tools around a page. They need Python, a browser or the network. |
The level editor server (level_editor.py) and the folder workflow (level_area.py) |
They are a local tool with an HTTP server and files. They call the Rust solver when it is at parity. |
emit.py (the Leaflet spec and roadstyle.js) |
It is the old Leaflet output. It stays as it is, or it is removed in a later major version. |
The crates¶
flowchart TB
model["roadstyle-model"]
look["roadstyle-look<br/>feature: schema"]
levels["roadstyle-levels"]
engine["roadstyle-engine<br/>feature: parallel"]
tiles["roadstyle-tiles<br/>features: parallel, pmtiles"]
io["roadstyle-io<br/>features: parquet, geojson, duckdb, page"]
facade["roadstyle (facade)<br/>default: io, tiles, levels"]
wasm["roadstyle-wasm (cdylib)"]
py["roadstyle-py (PyO3, abi3)"]
model --> engine
look --> engine
levels --> engine
model --> tiles
engine --> tiles
model --> io
look --> io
engine --> io
tiles --> io
model & look & engine & tiles & levels & io --> facade
facade --> wasm
facade --> py
Each arrow is a Cargo dependency. roadstyle-levels has no dependency on the other crates, so duckosm-core can take it alone.
The engine uses it to compute the numbers when the caller gives none.
| Crate | Content (from today's modules) | Dependencies | Builds for wasm32 |
|---|---|---|---|
roadstyle-model |
RoadTable and FeatureTable (columns over Arrow arrays), Geometry (one coordinate buffer with offsets), the render model (RoadDraw, Levels, End, Attachment, DrawKey), ColumnMap and its presets, RoadSource, validation, MAX_ROADS |
arrow-array, arrow-schema | yes |
roadstyle-look |
Look (the typed config), the bundled palettes and profiles, the resolution, the JSON schema (feature schema) |
serde, serde_json, schemars (optional) | yes |
roadstyle-levels |
levels/ as it is today: LevelEdges, compute, Method, Row |
rstar, sha2 | yes |
roadstyle-engine |
roads, simple, slots, layers, overlays, features, planner, geom; the LevelPlan rules, the piece generators, order (the sort key and the layer planner), linear referencing for attachments; data-driven color and width, the legend, the simplification |
model, look, levels, serde_json, rstar, indexmap; rayon (optional) | yes |
roadstyle-tiles |
tiles: the MVT encoder, the tile index, tiles on request, a tile cache; the PMTiles writer (feature pmtiles) |
model, engine; rayon (optional) | yes |
roadstyle-io |
the readers of Input and output formats (GeoParquet, Arrow IPC, GeoJSON, FlatGeobuf, CSV, DuckDB), the style writer (streamed), the tile folder, PMTiles, the page writer (feature page, with the dashboard bundle) |
parquet (snap), arrow-ipc, flatgeobuf, csv, duckdb: each optional | yes, without duckdb |
roadstyle |
The facade: re-exports and the Map builder. The only crate that a Rust user adds. |
all above | yes |
roadstyle-wasm |
The JS API (makeStyle, Tiler, resolveLook, memoryBytes), the allocator |
roadstyle (features io/parquet, tiles) |
only |
roadstyle-py |
The Python module roadstyle._engine |
roadstyle, pyo3, arrow (pyarrow interface) | no |
The JavaScript packages:
| Package | Content | Source |
|---|---|---|
roadstyle-wasm (npm) |
The WebAssembly module, the wasm-bindgen glue, roads_sql.js, features_sql.js, the TypeScript types |
rust/roadstyle-wasm/ |
roadstyle-dashboard (npm) |
The ES modules of task 2, dashboard.css, popup.css |
src/roadstyle/static/dashboard/ |
The CLI: the native program main.rs becomes a benchmark tool (rust/roadstyle/examples/bench.rs). The user CLI stays the
Python roadstyle program, which calls Rust. A Rust CLI with the same flags is a later step, when a user without Python asks for it.
Which user takes which crate¶
flowchart LR
subgraph users["Users"]
dcore["duckosm-core<br/>(native levels)"]
dpage["duckosm-rust page"]
geo["GeoLibre plugin"]
ms["mapstyle"]
pyu["Python users<br/>SonoFlow, sensor-matching, ..."]
lan["lanestyle, urbanstyle"]
end
levels["roadstyle-levels"]
wasm["roadstyle-wasm (npm)"]
dash["roadstyle-dashboard (npm)"]
pyw["roadstyle wheel<br/>(Python + roadstyle-py)"]
dcore --> levels
dpage --> wasm
dpage --> dash
geo --> wasm
geo --> dash
ms --> pyw
pyu --> pyw
lan --> pyw
The browser users take the npm packages. The Python users take one wheel and see no Rust.
Feature flags¶
| Flag | Crate | Effect | Default |
|---|---|---|---|
parallel |
engine, tiles | rayon: sources and tiles on all cores | off |
pmtiles |
tiles | The PMTiles archive writer | off in the crate, on in the facade |
parquet |
io | GeoParquet and Parquet reader (snap; zstd with zstd) |
on |
geojson |
io | GeoJSON and NDJSON reader | on |
ipc |
io | Arrow IPC stream reader | on |
fgb |
io | FlatGeobuf reader | off |
csv |
io | CSV with WKT reader | off |
proj |
io | Transform from another CRS to EPSG:4326 (pure Rust) | off |
duckdb |
io | DuckDB reader for <mode>.edges and visualization.edge_levels (native only) |
off |
page |
io | The HTML page writer with the dashboard bundle | off |
schema |
look | Look::json_schema() |
off |
Errors¶
Each crate has one error enum (thiserror), #[non_exhaustive]. The facade has roadstyle::Error, which holds each of them.
Each error says what is wrong, where (a column, a key path, a feature id) and what to do.
| Error | Examples |
|---|---|
DataError |
missing column highway; column layer has type List, expected integer or text; feature 812 has no line; 230,114 roads, the limit is 200,000 |
LookError |
roads.width.major: expected a map of zoom to px, found a number; unknown palette ambr (the names are amber, carto, highsat, mono); look version 3, this engine reads 1 and 2 |
EngineError |
a view names palette x that the look does not have |
TileError |
zoom 23 is past the last zoom 16 |
IoError |
Parquet file is not readable; the DuckDB file has no visualization.edge_levels (give the rs.save_levels command) |
In JavaScript, an error is a thrown Error with name = the kind (LookError) and a path property. In Python, each error is a
subclass of ValueError (roadstyle.LookError(ValueError)), so except ValueError in user code still works.
The data flow of one map¶
flowchart LR
subgraph where1["Python process (native)"]
gdf["GeoDataFrame"] -->|"to_arrow(), no copy"| arrow1["Arrow table"]
opts["render_edges keywords"] --> spec["LookSpec + MapOptions"]
end
subgraph where2["Browser: worker"]
sql["duckdb-wasm: roads_sql.js"] --> pq["roads.parquet bytes"]
end
subgraph core["Engine (native or wasm)"]
roads["RoadTable"]
lk["Look (resolved)"]
sty["Style + metadata"]
tl["Tiles on request"]
roads --> sty
lk --> sty
sty --> tl
end
arrow1 --> roads
pq --> roads
spec --> lk
subgraph where3["Browser: main thread"]
ml["MapLibre"]
db["dashboard module<br/>rsDashboard(map, options)"]
end
sty -->|"style JSON"| ml
tl -->|"rs:// or tile files"| ml
sty -->|"roadstyle:* metadata"| db
db -->|"rs* calls"| ml
The engine is the same code in both places. In the browser it runs in a worker, and the main thread only draws.
The steps:
- Input. Python gives an Arrow table (no copy from pyarrow). The browser gives the Parquet bytes that duckdb-wasm writes.
- Model. The reader maps the columns (
ColumnMap:highway_col,edge_id_col,tunnel_col, ...), checks them and makes aRoadTable. - Look.
Look::resolvemerges the palette, the profile and the settings, and adds the map options that change the style. - Style. The engine makes the layers, the sources and the metadata.
- Output. Inline (the sources in the style,
export_inline), tiled (Tiler, tiles on request), or files (export_style, the page). - Page. MapLibre draws the style. The dashboard module reads the metadata and adds the controls.
The render model¶
The render model is the set of values that the engine draws from: the drawing numbers of each road, the objects that belong to a
road, and the order of all layers of the map. It is the core of roadstyle, and today it has no type. Python bakes it as about 40
__rs_* properties (render_web.py), and the crate copies these properties (roads.rs, simple.rs, slots.rs). This section makes
it one typed model in roadstyle-model, with the rules in roadstyle-engine. The specification of the numbers stays
Divided casing and one band. This section does not change it.
flowchart LR
subgraph sources["Where each value comes from"]
cols["caller columns<br/>casing_level_col, fill_level_col, ..."]
stored["stored levels<br/>visualization.edge_levels + meta"]
edits["user edits<br/>edits.csv, heads.csv, caps.csv"]
tags["the tags<br/>layer, bridge, tunnel"]
objs["caller objects<br/>Overlay(edge_col), items"]
base["base-map features<br/>crossings, signals"]
end
plan["LevelPlan<br/>one source of numbers for each map"]
solver["roadstyle-levels<br/>Tags or Solve, with fixed roads"]
draw["RoadDraw<br/>numbers, ends, direction, look"]
att["Attachment<br/>edge, along, side, offset,<br/>kind, role, order"]
gen["generators<br/>casing parts, fills, seams, laps,<br/>twin casing, shadows, slots"]
key["DrawKey<br/>position, phase, order, tie"]
planner["layer planner<br/>merged or per position"]
out["MapLibre layers + sources"]
cols --> plan
stored --> plan
tags --> solver
edits --> solver
plan -->|"no numbers, or some"| solver
solver --> plan
plan --> draw
objs --> att
base -->|"snap to the edge on top"| att
draw --> gen
gen --> key
att --> key
draw -->|"position of the edge"| att
key --> planner
planner --> out
Each value has one source. Every piece (a casing part, a fill, a name, a lane, a sign) gets one DrawKey, and one planner turns the keys into layers.
Words¶
| Word | Meaning |
|---|---|
| edge | one row of the input: one line with one direction |
| road | both directions of one segment. The solver works on roads, and the result is copied to each edge of the road |
| position | a drawing number. A higher position is drawn later. 0 is the ground |
| piece | one feature that the engine draws: a casing part, a fill, a fill half, a seam, a lap, a shadow, a name slot, an attached object |
| attachment | an object that belongs to an edge and is drawn at the place of that edge |
| role | the name of a style in the profile (lane, divider, sign), which an attachment names (overlay_styles.md) |
| draw key | the place of a piece in the order of the map |
The numbers and ends of a road: RoadDraw¶
RoadDraw holds one row for each edge, as columns:
| Field | Type | Meaning | Default |
|---|---|---|---|
edge_id |
EdgeId (i64, or text for other schemas) |
the id of the edge | required |
class |
dictionary text, and is_link |
the road class (highway without _link) |
the fallback style |
geometry |
a line in the coordinate buffer | the edge's line | required |
levels |
Levels { casing_start, casing_main, casing_end, fill: i16 } |
the four positions | see the rules below |
ends |
[End; 2], End { head_m: Option<f32>, cap: Cap }, Cap = Round, Square, Flat |
the head length and the shape of each end | head_m of the profile (5 m), Round |
direction |
directed: Option<bool>, oneway: Option<bool>, driving: Option<bool> |
two lanes or one line; the arrow | null: as today (directed null = directed, oneway null = no twin) |
structure |
tag_level: i8, bridge: bool, tunnel: bool |
the look (deck, tunnel) and the Bridges and Tunnels switches; not the order | 0, false, false |
look |
access, width_m, the data-driven paint (task 1) |
the colors and widths of this edge | none |
| derived | twin, single_line, split |
found by the engine: the reverse edge, a pair drawn as one line, two different caps |
The engine derives the pieces from RoadDraw with the rules of today: three casing parts (start head, main, end head), or two
halves for a short road (one cut, short_road_heads.md), or one piece when the three numbers are equal; seams of 0.5 m and laps
of 2 m at each cut; one casing for a two-way pair (twin_casing = "one"); fill halves when the two caps differ; bridge shadows;
tunnel gap and dashes.
Where the numbers come from: LevelPlan¶
pub enum LevelPlan {
Given(LevelColumns), // the caller's columns (casing_level_col, fill_level_col, ...)
Stored { table: LevelColumns, meta: LevelMeta }, // visualization.edge_levels and edge_levels_meta
Compute { method: Method, params: SolveParams, edits: Edits }, // Tags, or Solve with the rule rows
Partial { given: LevelColumns, method: Method, params: SolveParams, edits: Edits }, // some roads given, the rest solved
}
The rules:
- One source of numbers for one map. The positions are a ranking for the whole network: a number of one road means something
only against the numbers of the roads that it meets or crosses. Thus all numbers of one map come from one solve or one stored
table (
levels_split_casing.md, section 12.1). The engine does not mix two sources by a default. - Some numbers given, the rest computed. The caller gives the numbers of some roads. These roads are held (
fixed: all four numbers of a whole road), and the solver computes the others with them as bounds. Python has this assolve_levels(fixed=...)(the editor's local solve). It is the only way to mix: a road with no number is never drawn at 0 without a message. - The ends are not the numbers. For each end, the first source that has a value wins: the user's edit (
heads.csv,caps.csv), the caller's column (head_start_m_col,cap_start_col, ...), the solver's result (the square cap at the dead end of a two-way road,auto_endswhen asked), the profile (head_m, round). - Edits are rules, not numbers. An edit adds a row to the solver's input or switches one off (
enabled = falseremoves the same row ofpairs.csv). It never sets a number. The Rust solver has no edits today (levels/mod.rs:3), and P4 adds them. - Stored numbers are checked. The engine compares the stored parameters (
method,head_m,band_dist,margin,max_level,band_source,order_source,min_positions), the number of edges and the edge hash. An edge with no stored row is an error, as in Python'slevels_db. (The worker SQL of today joins withLEFT JOIN, and a missing row becomes 0.) - One normalization. A value that is not a number is an error with
GivenandStored(Python reads it as 0 today: P0 lists this as a difference to decide). A float is rounded half to even. The main casing number is never above the fill, and each head is lowered to the fill. A reversed edge has its start and end swapped. - The source is in the style. The metadata
roadstyle:levelsholds the method, the edge hash, the version and the pairs that the solver gave up, so a page can show where the numbers come from.
Objects that belong to a road: Attachment¶
Today four mechanisms put objects on roads: the slots (names and arrows), Overlay(edge_col=...), items= (metre-wide lines), and
edge_attach (the base map's crossings and traffic signals, by distance). Each one carries its edge in its own way. edge_items.md
asks for one mechanism. The model is one type:
pub struct Attachment {
pub edge: EdgeId, // the edge that it belongs to
pub at: Along, // Geometry(given) | Whole | At { m } | Span { from_m, to_m }: metres along the edge
pub side: Side, // Center | Right | Left, of the edge's direction
pub offset_m: f32, // from the side
pub kind: Kind, // Line | Fill | Circle | Icon | Text
pub role: RoleId, // a style of the profile: "lane", "divider", "sign", "sensor", "bus_stop"
pub order: i16, // inside the position: the lower is drawn first
pub phase: Phase, // OverFill (default) | WithCasing
pub row: u32, // the row of its data, for the popup
}
- What it gets from its edge. The position (the edge's fill number), the tunnel look, the filters (hiding a road hides its
objects) and the selection (a click selects the road too, unless the role says
interactive: false). Every piece carries the same edge label (__rs_edge, and__rs_edge2for both directions of a pair). - The place.
Geometryis today's case: the caller gives the line, polygon or point.At,Span,Sideandoffset_mare new: the engine cuts the edge's line at the metres (linear referencing) and moves the result to the side. A sensor at 40 m on the right, a bus stop at the end, a lane marking from 10 m to 60 m need no geometry from the caller. An object on one lane of a two-way road follows the lane's offset in pixels (the expression of the road), not a fixed number of metres. - Built in. The slots are attachments that the engine makes: a name is
Texton aSpan(an even slot), an arrow isIconon aSpan(an odd slot). The base map's crossings and traffic signals become attachments when they are snapped to the edge that is drawn on top (edge_attach). - New kinds of objects need no change of the core. A new object is a new role: a style entry in a profile (its
kindand its paint values) and a palette key for its color. The five kinds are a closed list in the core. A sixth kind is a change of the core and of the layer planner. - Errors. An edge id that is not among the roads is an error with the count and the first ids, as in Python today.
The order of the whole map: DrawKey¶
Every piece has one key, DrawKey { position, phase, order, tie }. The order of the map is the order of the keys:
flowchart TB
bg["background, base map (raster or the base-map layers)"]
under["overlays with placement under"]
subgraph pos["for each position p, lowest first"]
direction TB
c["1 casings: heads, main, seams, laps;<br/>bridge shadow, bridge casing; tunnel gap and dashes"]
cw["2 attachments with phase WithCasing (new)"]
f["3 fills: dashed classes first, then fills and fill halves"]
a["4 attachments, by order, then by overlay"]
ar["5 one-way arrows"]
lb["6 street names"]
c --> cw --> f --> a --> ar --> lb
end
hl["hover and selection (roads-highlight)"]
deck["3D: bridge decks, buildings"]
bnd["boundary"]
over["overlays with placement over"]
bg --> under --> pos --> hl --> deck --> bnd --> over
At one position every casing is under every fill. A higher position is over the whole of a lower one, casing included.
MapLibre orders the layers by their place in the list, and the features of one layer by line-sort-key (or symbol-sort-key). A
sort key works only inside one layer. Thus the layer planner turns the keys into layers in one of two ways:
| Plan | Layers | Sort key | Today |
|---|---|---|---|
Merged (simple=True, the default) |
one line layer for every line piece of every position (roads-simple); the other kinds in their own layers |
2p for a casing (+0.25 bridge, -0.1 shadow, +0.1 tunnel dashes), 2p - 0.5 for a dashed fill, 2p + 1 for a fill, 2p + 1 + 0.01 order for a line attachment, + 1e-8 edge |
Python and Rust |
Per position (simple=False) |
for each position, a casing layer, a fill layer, the attachment layers, an arrow layer and a name layer, in the order of the diagram | constant | Python only; not ported (decision 2) |
The key formula is one function in roadstyle-engine (order::sort_key), and both plans use it. Today it is written two times
(_simple_pieces in Python, simple.rs in Rust).
The limits of the merged plan, as in Python today:
- Only line pieces share the one layer. A polygon, a circle, an icon or a text attachment is in a layer of its own above the whole
road layer. Thus it does not go under a road at a higher position (Python: only metre-wide line items are pieces of the road layer;
Rust: no item at all yet,
__rs_k = 5is read and never made). - The names and the arrows are one layer each, above all roads. A name of a road under a bridge can show on the bridge.
- A later improvement can split the merged layer at the positions that have such attachments (a filter on the sort key), with one more layer for each such position. It is new behavior: see decision 8.
Input and output formats¶
All inputs become one form¶
Each reader is an adapter that gives Arrow record batches. The model makes one RoadTable from them (the columns of RoadDraw,
plus the other columns for popups as dictionary or plain arrays), and a FeatureTable for the base map and the overlays. The engine
reads only these two types.
pub trait RoadSource {
fn schema(&self) -> SchemaRef;
fn next_batch(&mut self) -> Option<Result<RecordBatch, DataError>>;
}
pub fn read_roads(src: impl RoadSource, map: &ColumnMap) -> Result<RoadTable, DataError>;
flowchart LR
gp["GeoParquet"] --> rb
ipc["Arrow IPC<br/>(Arrow JS, duckdb-wasm)"] --> rb
cdi["Arrow C Data Interface<br/>(pyarrow, GeoPandas, Polars, DuckDB)"] --> rb
gj["GeoJSON, NDJSON"] --> rb
fgb["FlatGeobuf"] --> rb
csv["CSV with WKT"] --> rb
ddb["DuckDB tables<br/>(GeoPackage, PostGIS through DuckDB)"] --> rb
mem["Rust builder, JS objects"] --> rb
rb["Arrow record batches<br/>(RoadSource)"] --> cm["ColumnMap + preset<br/>check, CRS"]
cm --> rt["RoadTable, FeatureTable"]
rt --> eng["engine"]
Every format ends as Arrow batches, so the column map and the checks are written once.
| Input | Adapter | Feature | Native | WebAssembly | Python | No copy of the columns |
|---|---|---|---|---|---|---|
| GeoParquet (WKB or GeoArrow geometry) | io::parquet |
parquet (default) |
yes | yes | yes | no: one decode |
Arrow C Data Interface (pyarrow, GeoPandas to_arrow, Polars, DuckDB) |
roadstyle-py, io::arrow |
core | yes | no | yes | yes |
| Arrow IPC stream (Arrow JS, duckdb-wasm) | io::ipc |
ipc |
yes | yes | yes | one copy into the module, then yes |
| GeoArrow native coordinates | model | core | yes | yes | yes | yes |
| WKB column | geom::wkb |
core | yes | yes | yes | no: one decode |
| GeoJSON, newline-delimited GeoJSON | io::geojson (a streamed parse, no JSON tree) |
geojson |
yes | yes | yes | no |
| FlatGeobuf (with a box filter) | io::fgb (flatgeobuf crate, pure Rust) |
fgb |
yes | yes | yes | no |
| CSV with a WKT column | io::csv |
csv |
yes | yes | yes | no |
| DuckDB table or query | io::duckdb (DuckDB's Arrow interface) |
duckdb |
yes | through duckdb-wasm and Arrow IPC or Parquet | through Python's duckdb | yes natively |
| GeoPackage, PostGIS | through DuckDB (spatial, postgres extensions) or GeoPandas |
duckdb |
yes | no | yes | no |
| Rust values, JS objects | RoadTableBuilder, Tiler.fromRows |
core | yes | yes | no | Rust yes, JS no |
| GeoDataFrame | to_arrow(), then the C Data Interface |
Python | yes | WKB decode only |
Large areas. The reader takes batches (for example 65,536 rows), maps and checks each one, adds it to the columns and frees it: the peak is the table and one batch, not a JSON tree. A box filter reads only the rows in an area (FlatGeobuf's index, the bounding box columns of GeoParquet 1.1). The engine itself needs the whole table, because the twins, the chains of the slots, the levels and the camera are global. The output streams: the style is written source by source, and the tiles are made on request.
Memory of each path. Arrow C Data: the columns are not copied, and only WKB is decoded into the coordinate buffer. Arrow IPC in a browser: one copy into the module. GeoParquet and the other files: one decode, and the file bytes are freed after it. GeoJSON: one streamed pass. P4 measures each path on Tartu.
Schemas: the column map and its presets¶
ColumnMap names the column of each field of RoadDraw (the Python keywords highway_col, edge_id_col, tunnel_col, bridge_col,
layer_col, directed_col, driving_col, access_col, the level and end columns). A preset is a ColumnMap with a conversion:
| Preset | Class | Id | Bridge, tunnel, layer | Direction | Conversion |
|---|---|---|---|---|---|
duckosm |
highway |
edge_id (BIGINT hash) |
bridge, tunnel, layer |
oneway, is_directed, has_arrows |
the levels from visualization.edge_levels |
osmnx |
highway |
(u, v, key) |
bridge, tunnel, layer |
oneway, reversed |
a list cell becomes its first value that is not null (_flatten_osmnx); a warning for an edge that is both bridge and tunnel |
overture |
class (subclass = link gives is_link) |
id (text) |
road_flags (is_bridge, is_tunnel), level_rules |
access_restrictions (to check) |
a flag on a part of a segment (between) splits the segment at that part |
gmns |
facility_type, through a class table |
link_id |
none | directed |
the class table is part of the preset |
nvdb |
to define with fetching-sweden-data | ||||
| own table | the keywords | the keywords | the keywords | the keywords | none |
A missing column is an error that names the preset, the column and the nearest names in the table. A column of the wrong type is an error that names the type found.
Geometry. A road is a LineString or a MultiLineString. A MultiLineString is one piece, as in Python today, and it gets no heads.
The base map, the overlays and the attachments take every geometry type. The CRS. The model is in EPSG:4326. The Python
front-end transforms with GeoPandas, as today (validate.to_wgs84). A Rust reader reads the CRS of the file: EPSG:4326 or OGC:CRS84
is read, another CRS is an error, or it is transformed with the feature proj (pure Rust, so it builds for WebAssembly).
Outputs¶
| Output | Made by | Native | WebAssembly | Python |
|---|---|---|---|---|
MapLibre style with the sources in it (export_inline, makeStyle) |
engine, io |
yes | yes | yes |
Style with vector sources, and tiles on request (Tiler) |
tiles | yes | yes | yes |
A tile folder (tiles/{z}/{x}/{y}.pbf), a sprite (export_style) |
io |
yes | no | yes |
| A PMTiles file | tiles, feature pmtiles |
yes | yes (bytes) | yes |
| The HTML page | io, feature page |
yes | no | yes |
| The data for folium: GeoJSON with the paint of each edge | engine (bake) |
yes | yes | yes |
| The data for lonboard: casing and fill color (RGBA) and width of each edge, as Arrow arrays | engine (paint_arrays) |
yes | yes | yes, as numpy arrays with no copy |
The legend: metadata["roadstyle:color_options"] |
engine (task 1) | yes | yes | yes |
The level rows (for visualization.edge_levels) |
levels | yes | yes | yes |
The interaction contract¶
The interaction contract is everything that a dashboard can read from the rendered output and change in it at run time. It has
three users: the Python page (the dashboard bundle in one HTML file), duckosm-rust's page (ES modules, the engine in a worker) and the
GeoLibre plugin (ES modules on GeoLibre's map). Today the contract is not written down. The dashboard module (task 2, branch
claude/dashboard-js-5ddf3e, 19 files, 1,684 lines) reads 3 metadata keys, about 20 __rs_* properties and layer ids that start
with roads. It changes the map with 7 kinds of calls and has 31 rs* functions (29 of them on window), with no types and no version,
and 15 rs:* events with names that clash (rs:viewchange is the 3D button, rs:viewselect is the View menu).
flowchart LR
subgraph engine["Engine (Python, native, worker)"]
st["style: layers + sources"]
md["metadata: roadstyle:contract,<br/>controls, state, color_options,<br/>interaction, images, levels"]
end
subgraph map["MapLibre map"]
lay["layers<br/>(role and group in their metadata)"]
gs["global-state<br/>rs_co, rs_colors, rs_fill_on, ..."]
fs["feature-state<br/>hover, select"]
end
subgraph dash["roadstyle-dashboard"]
state["DashboardState"]
api["rs object: methods, on(), getState(), setState()"]
ctl["controls: Colour by, legend,<br/>Roads box, Layers, Tunnels, View"]
end
host["host page<br/>(Python page, duckosm-rust, GeoLibre)"]
url["URL, saved views"]
st --> lay
md -->|"read once"| state
ctl --> state
api --> state
state -->|"setGlobalStateProperty"| gs
state -->|"setFeatureState"| fs
state -->|"setFilter, visibility"| lay
gs --> lay
fs --> lay
state -->|"restyle(change), if an engine is attached"| engine
engine -->|"style patch"| lay
host <-->|"calls and events"| api
url <-->|"getState / setState"| api
The engine writes the contract into the style. The dashboard keeps one state and changes the map only through MapLibre's own calls. Only a change that needs new data goes back to the engine.
The output: what the engine writes for the dashboard¶
The style carries a version, metadata["roadstyle:contract"] = 1, and each key below has a JSON schema
(contract.schema.json, made from the Rust types in roadstyle-model::contract). The TypeScript types of the npm packages are made
from the same schema.
| Key | Content | Today |
|---|---|---|
roadstyle:contract |
the version of this contract | new |
roadstyle:interaction |
the popup fields, the tooltip fields, the hover delay | Python export, Rust |
roadstyle:color_options |
[{name, prop, legend}], and the active option |
Python export, Rust; the active option is new (the legend is correct only for option 0 today) |
roadstyle:controls |
what the engine knows and the controls show: the classes and their swatches, the access kinds and their counts, the tunnel look (palettes, colors, steps), the views, the arrow image and rules | new: today Python gives these as rsDashboard options, and the Rust users have none |
roadstyle:state |
each global state name and its start value (rs_co, rs_colors, rs_base, rs_fill_on, rs_palette, rs_ov_<n>, rs_tun_*) |
new: today the names are written in the JS and in render_web.py |
roadstyle:images |
the images to add (oneway) |
Rust |
roadstyle:levels |
the source of the numbers (see the render model) | new |
roadstyle:planner, roadstyle:planner_skipped |
the route planner's graphs | Rust |
layer metadata["roadstyle:group"], ["roadstyle:over"] |
the group of a layer (roads, or an overlay's name) |
Rust |
layer metadata["roadstyle:role"] |
casing, fill, item, arrows, labels, highlight, deck, base |
new |
The rules:
- Layers are found by their role and group, not by their id. The ids change with the plan (
roads-fill-lv3) and a host can add a prefix (duckosm-rust addsrs-, GeoLibre renames the layers). Todayfilter.jstakes every layer whose id starts withroads. Until the layers have roles, the optionprefix(for example"rs-") tells the dashboard the prefix of the roads style's source and layer ids in the map; the names inside the style (source-layer, metadata) do not have it. - Public and internal. Public for a host: the metadata keys above, the layer roles and groups, the data columns (
edge_id, the class column and every column kept in the tiles), and the anchor layerroads-highlight. Internal (between the engine and the dashboard of the same version, checked by a test): the__rs_*properties that the dashboard reads (__rs_edge,__rs_edge2,__rs_edges,__rs_cls,__rs_lvl,__rs_tunnel,__rs_bridge,__rs_access,__rs_k,__rs_fl,__rs_ord,__rs_arw, the slot fields), the feature ids, and the layer ids. - The ids. A feature's id is its row in the table (the roads source), or the row times 24 plus the piece (the
simplesource), because feature state needs small numbers. With tiles and the simple mode, thesimplesource haspromoteId: "__rs_edge", so the glow of a road is the feature state of its row (the engine's road tiles have no lines). The public API takes the edge id (edge_id, as text, so a 64-bit hash stays exact), and the dashboard maps it to the row. The engine portpropstakes the row today; it takes the edge id when the map from edge id to row is in the contract. - The state stays live. For a page with a dashboard, the engine writes
["global-state", name]in the expressions (state: live). For a static style (export_style, a plain MapLibre app, RustGIS), it writes the start values (state: baked). Today the Rust engine always bakes them, so the Colour by menu,rsColor, the road fill switch and the Tunnels box do nothing on its style (dashboard_module.md).
The state model¶
DashboardState is one object. Each control changes it, and the dashboard applies a change with the cheapest mechanism:
| Control | State | Mechanism | Needs the engine again |
|---|---|---|---|
| hover, click, select | selection |
feature-state (hover, select) on the road, its pieces, its decks and its items |
no |
| highlight a set of roads | highlight |
feature-state |
no |
| Colour by, an option that the style has | colorBy |
global state rs_co |
no |
rsColor groups, the base groups |
colors, colorBase |
global state rs_colors, rs_base (edge to color) |
no |
Colour by, a column that the style does not have, a new ramp, vmin, vmax, width_by |
colorBy |
restyle: new paint expressions and a new legend |
yes |
| the legend | from colorBy |
the DOM, from roadstyle:color_options |
no (yes with a new column) |
| classes, Bridges, Tunnels, access kinds, a set of ids | filter |
setFilter on every layer of a road, through one function (_applyRoadFilters) |
no |
| an overlay on or off | overlays |
layer visibility | no |
| an overlay's color option | overlays |
global state rs_ov_<n> |
no |
| the road fill on or off | roadFill |
global state rs_fill_on |
no |
| the tunnel look (strength, color, palette, dash ratio) | tunnel |
global state rs_tun_* |
no |
| a palette that a view names | palette |
global state rs_palette (the style has a copy of the colors for each such palette) |
no; another palette: yes |
| another profile (widths, zooms) | a new style (setStyle) |
yes | |
| a view | view |
setState with the view's part of the state |
no |
| the base map | basemap |
the raster source and layer made again | no |
| 2D or 3D | view3d |
the camera pitch and the deck layers | no |
| Street View, Google 3D | streetView, google3d |
the windows and Google's API | no |
| the side panel | panel |
the DOM | no |
| the camera | camera |
jumpTo |
no |
| the one-way arrows | setData on the arrows source after each move |
no | |
| a level edit | the engine solves again; updateData (inline) or a new tile generation |
yes |
The runtime API¶
rsDashboard(map, options) returns one object. The Python page sets window.rs to it. The dashboard module already returns it
(branch claude/dashboard-js-5ddf3e), with one method, setStyle; the other methods below go on the same object. style: null is
allowed: the roads come later, with setStyle.
type EdgeId = string;
interface DashboardState {
v: 1;
selection: EdgeId | null;
highlight: EdgeId[];
colorBy: string; // the name of a color option
colors: { color: string; ids: EdgeId[] }[]; // rsColor
filter: { classes: string[] | null; bridges: boolean; tunnels: boolean; access: string[]; ids: EdgeId[] | null };
overlays: Record<string, { visible: boolean; colorBy?: string }>;
roadFill: boolean;
tunnel: { strength: number; toward: string; palette: string; ratio: [number, number] };
palette: string;
view: string | null;
basemap: string;
view3d: boolean;
camera: { center: [number, number]; zoom: number; pitch: number; bearing: number };
streetView: { mode: "off" | "window" | "panel"; spot?: { edge: EdgeId; m: number } };
google3d: boolean;
panel: boolean;
}
interface Dashboard {
readonly contract: number;
setStyle(style: object): void; // a late or a new roads style: the page adds its sources and layers first;
// the controls stay, the menu and the legend are made again, the selection is cleared
select(id: EdgeId | null): void;
highlight(ids: EdgeId[]): void;
focus(ids: EdgeId[]): void;
query(where: Record<string, unknown>): EdgeId[];
find(text: string): EdgeId[];
props(id: EdgeId): Record<string, unknown> | null;
setColorBy(name: string): void;
color(groups: DashboardState["colors"] | null): void;
setFilter(filter: Partial<DashboardState["filter"]>): void;
setOverlay(name: string, visible: boolean): void;
setTunnelStyle(look: Partial<DashboardState["tunnel"]>): void;
setView(name: string): void;
setBasemap(key: string): void;
getState(): DashboardState;
setState(change: Partial<DashboardState>): void; // a saved view, a URL
can(change: Partial<DashboardState>): boolean; // false when the change needs an engine and none is attached
on(event: DashboardEvent, fn: (e: unknown) => void): () => void; // returns the function that removes it
destroy(): void;
}
type DashboardEvent = "select" | "deselect" | "hover" | "state" | "filter" | "color" | "overlay" | "tunnel"
| "view" | "view3d" | "basemap" | "streetview" | "google3d";
- Events.
on()takes the names above. The eventstategives each change as the part of the state that changed. The olddocumentevents (rs:select,rs:filterchange, ...) are sent too, for one minor version. - Saved views and URLs.
getState()gives the whole state, andsetState()takes a part of it. A URL keeps the part that differs from the start state (#rs=and the JSON in base64url). A view ofviews=is a named part of the state, so the View menu callssetState. - The old names.
window.rsSelect,window.rsSetColorFieldand the otherwindow.rs*functions stay as aliases of the object's methods for one minor version, with one warning in the console. Then they go. A name that starts with_is not public, also when the Python page makes it a global.
When the dashboard needs the engine again¶
Some changes need new data or new expressions: a color column that the style does not have, a new ramp or range, width_by, another
palette or profile, a level edit. The dashboard asks for them through a port, so that it does not depend on WebAssembly:
rsDashboard(map, {style, prefix: "rs-", engine: {
props: (row) => worker.props(row), // one road's properties on request: a click, rsSelect, the tooltip (built)
restyle: (change) => worker.restyle(change), // a Promise of a StylePatch (planned)
}});
Tiler.restyle(change)inroadstyle-wasmgives aStylePatch: the new paint and filter expressions of each layer role, the newroadstyle:color_optionsentry with its legend and, when the tiles must change, a new tile generation (rs://<generation>/..., as duckosm-rust's page does today with a new worker for each style). The worker keeps the roads table, so it computes the categories, the ramp and the legend from the column without a new read.- A color by a column is an expression on a property that is in the source (
["get", column]in amatchor aninterpolate), so only the paint changes. For this, the column must be in the tiles (keep). Python bakes a color column for each option today (__rs_fill__<i>); the expression form is new behavior (decision 8). - The Python page has no engine. It shows only the options that it has in the style.
can()returns false for a change that needs the engine, and the control does not show that choice. Nothing changes without a message. - duckosm-rust's page and the GeoLibre plugin attach the worker's
restyle.
The inventory of shared variables¶
The tables below list every variable that the engine, the dashboard and the page share at commit 686fe4c (branch
claude/dashboard-js-5ddf3e, which includes rust-engine). These commands make the name lists again:
git grep -ohE '"rs_[a-z0-9_]+"|rs_ov_' -- src rust | tr -d '"' | sort -u # the global state: 12 names
git grep -ohE '__rs_[A-Za-z0-9_]+' -- src rust | sort -u # the properties: 66 names
git grep -ohE 'roadstyle:[a-z_]+' -- src rust | sort -u # the metadata keys
git grep -ohE '__[A-Z][A-Z0-9_]+__' -- src/roadstyle/static/web_template.html | sort -u # the page placeholders
git grep -nE 'localStorage|--rs-' -- src/roadstyle/static # the stored keys and the CSS variables
These tables are in docs/reference/contract.md now, and a test (tests/test_contract_names.py) runs the same searches and fails
when the code has a name that the reference does not have, or the reverse.
The tables are in the contract reference (P3): the page and control variables, the global state rs_*
and the feature properties __rs_*, with the metadata keys, the dashboard's options from the style, the page's placeholders and the
storage keys. tests/test_contract_names.py checks them against the code.
The gaps that the contract closes¶
- The hover color, the select color and the glow are literals in the style: the global states
rs_hover,rs_select,rs_glowandsetSelectionStyle. The dead__HOVER_COLOR__replacement goes. select_coloris also arsDashboardoption (the Google 3D line): the dashboard reads it fromroadstyle:controls.- The legend has no active option in the metadata:
roadstyle:color_optionsgets it. - A Rust style has no overlay list for the Layers box (the 3D buildings, the base-map layers):
roadstyle:controls.overlays. - The camera has no getter or setter:
camerain the state. - The popup fields, the tooltip fields and the hover delay are fixed:
popup,tooltip,hoverDelayin the state. - Arrows and names cannot be switched off at run time:
labelsandarrowsin the state (layer visibility). copy_fieldis in the look'sargsbut the web page does not use it: a copy button in the popup, or remove it from the web contract (P0 decides with the parity list).- The map page has no light and dark theme, and
dashboard.cssandpopup.csshave literal colors: CSS variables--rs-*from auisection of the palette (open point 3 ofstyle_config.md), anddata-themeon the page. - There are no URL parameters:
#rs=withgetStateandsetState.
The config: the look¶
One name for each thing¶
| Name | Meaning |
|---|---|
| palette | Only colors (data/palettes/<name>.yaml) |
| profile | All values that are not colors: widths by zoom, zoom ranges, dashes, caps, the tunnel look (data/profiles/<name>.yaml) |
| settings | Overrides from roadstyle.json, $ROADSTYLE_CONFIG, use_settings(...), render_edges(settings=...) |
| map options | The keywords of one map that change the style (arrows, labels, view_3d, boundary, the column names, ...) |
| page options | The keywords of one map that change only the page (road_popup, street_view, filter_control, ...) |
| look | The result of the resolution: one typed, versioned value that the engine reads |
The resolution¶
flowchart LR
bp["bundled profiles<br/>default, highsat"] --> mp["profile = default + named profile"]
up["own profiles<br/>./profiles, ~/.config/roadstyle"] --> mp
bpal["bundled palettes<br/>amber, carto, highsat, mono"] --> mpal["palette = amber + named palette"]
upal["own palettes<br/>./palettes, ~/.config/roadstyle"] --> mpal
mp --> merge["deep merge:<br/>profile, then palette,<br/>then settings in order"]
mpal --> merge
st["settings<br/>roadstyle.json, $ROADSTYLE_CONFIG,<br/>use_settings, settings="] --> merge
merge --> val["validate:<br/>schema, units, colors, zoom keys"]
mo["map options"] --> val
val --> look["Look v1<br/>schema_version, theme,<br/>palettes for views, options"]
look --> hash["look hash<br/>(cache key)"]
The defaults are the bundled default profile and amber palette. A user file states only what it changes.
The rules:
- The merge is the merge of
_settings.pytoday: the named profile overdefault, the named palette overamber, the palette over the profile, then each settings source in order (lowest first:~/.config/roadstyle/roadstyle.json,./roadstyle.json,$ROADSTYLE_CONFIG,use_settings,settings=). The old keys (_OLD_*tables) are read with a deprecation warning, as today. - Validation after the merge. An unknown key is an error with its path and the nearest known key. Today an unknown key is ignored without a message.
- The file reading is an adapter. The core resolves from values (JSON text or Rust structs). The browser gives the files that it has. The bundled palettes and profiles are in the crate (made from the YAML files when the crate builds).
- The look has
schema_version(an integer, starts at 1). The engine reads version N and N-1. A change that removes or renames a key increments the version. - The JSON schema of the look is published with each release (
look.schema.json). An editor can check a user file with it. - One copy of the bundled files:
src/roadstyle/data/until phase 5. Then the files move intorust/roadstyle-look/data/, and Python reads the bundled values from the engine.
The typed look¶
pub struct Look {
pub schema_version: u32,
pub profile: String, // the names, for the page and the cache
pub palette: String,
pub roads: RoadLook, // classes (group, order, minzoom, width, casing, dash), widths by zoom, split, links, caps
pub casing: CasingLook,
pub labels: LabelLook,
pub arrows: ArrowLook,
pub bridges: BridgeLook,
pub tunnels: TunnelLook,
pub selection: SelectionLook,
pub data: DataLook, // no-data color, opacities of data maps
pub overlays: OverlayLook,
pub boundary: BoundaryLook,
pub background: Background,
pub access: AccessColors,
pub palettes: IndexMap<String, PaletteColors>, // the palettes that views name (`__rs_fill@mono`)
pub tiles: TileLook, // zooms 6 to 15, extent 4096, buffer 80 (defaults.json)
pub camera: CameraLook, // pitch_3d 55, max_pitch 70
pub options: StyleOptions, // the map options: columns, arrows, labels, view_3d, boundary, head_m, ...
}
Each struct derives Serialize, Deserialize, Default (the bundled values) and JsonSchema (feature schema), and has
#[serde(deny_unknown_fields)]. A color is a Color type (it parses #rrggbb, #rrggbbaa, rgba(...)), not a text. A zoom
table is ZoomTable (sorted zoom keys).
The public API¶
Rust¶
use roadstyle::{io, Look, LookSpec, Map, MapOptions};
let look = Look::resolve(&LookSpec::new("default", "amber"))?; // the bundled profile and palette
let roads = io::parquet::read_roads(&bytes, &io::ColumnMap::default())?; // RoadTable
let map = Map::new(&roads, &look, MapOptions { arrows: true, labels: true, ..Default::default() })?;
let style: String = map.style_json()?; // export_inline: the sources in the style
let tiled = map.tiled("rs://roads/")?; // the style with vector sources, and the tiles
let bytes: Vec<u8> = tiled.tile("roads", 14, 8800, 4700);
io::write_style_dir(&map, "out/", io::TileFormat::Pmtiles)?; // export_style
The level solver alone (duckosm-core):
use roadstyle_levels::{LevelEdges, Method, compute};
let mut edges = LevelEdges::default();
edges.add(0, edge); // rank 0 = driving
let levels = compute(&edges, Method::Solve)?;
JavaScript¶
The JS API of today stays the same through phase 3, so the two users do not break. The new form takes one options object:
import init, { Tiler, resolveLook, memoryBytes } from "roadstyle-wasm";
await init();
const look = resolveLook({ profile: "default", palette: "amber", settings: {} }); // JSON text
const tiler = new Tiler(roadsParquet, look, { features, name: "Tartu", prefix: "rs://1/", keep: ["name"] });
const style = JSON.parse(tiler.style());
// in the main thread
import { rsDashboard } from "roadstyle-dashboard";
const map = new maplibregl.Map({ container: "map", style });
rsDashboard(map, { style, street_view: "window" });
Python¶
The API does not change. render_edges(gdf, ...) keeps its 21 keywords, the web keywords and the result type WebMap. Inside, it
calls roadstyle._engine for each part that is at parity (see the phases).
Stability rules¶
| Item | Rule |
|---|---|
| Version numbers | One version for all crates, the wheel and the npm packages (lockstep), the same as Python roadstyle. A release has one tag. |
| Before 1.0 | A minor version (0.x to 0.y) can break the API. A patch version cannot. The CHANGELOG lists each break and what to change. |
| Public API | The roadstyle facade, the JS API of roadstyle-wasm, rsDashboard and the rs* functions, the Python API, the look schema, the roadstyle:* metadata keys. The inner crates are published, but their API follows the facade and can change in a minor version. |
| The look schema | schema_version is separate. The engine reads N and N-1. |
| The output | A change of the style or of the tiles is a change of behavior: a new golden, a CHANGELOG line, and the same change in Python first while Python is the reference. |
| Deprecation | A removed keyword or JS function gives a warning for one minor version, then an error. |
| Rust version (MSRV) | The Rust version of duckosm-rust's toolchain, written in rust-toolchain.toml and in CI. |
Efficiency¶
Numbers today (native release build, peak resident memory from /usr/bin/time)¶
| Area | Roads | Output | Memory | Time | Source |
|---|---|---|---|---|---|
| Monaco | 13,298 | style | 196 MB | 0.7 s | rust_engine.md |
| Tartu | 63,965 | style | 1.13 GB | 5.1 s | rust_engine.md |
| Tartu | 63,965 | tiles (make_tiled) |
1.01 GB | rust_engine.md |
|
| Tartu | tiles: the last zoom, after step 2 | 18.8 MB, 313 tiles | branch rust-tiles |
||
| Burnaby | 157,808 | style | 6.2 s | the session brief (not in a file of the repository) | |
| Burnaby | 157,808 | tiles of one 3 x 3 view, zoom 12 | 0.8 s (slowest tile 0.17 s) | branch rust-tile-speed |
|
| Burnaby | 79,199 | levels (solve) | 3.1 s native, 5.2 s wasm | duckosm-rust docs/levels.md |
Tartu uses about 17 KB for each road natively. The browser module used 0.65 to 0.77 times the native peak in earlier
measurements. MAX_ROADS = 200,000 is an estimate from Tartu only.
Targets¶
| Measure | Today | Target | How |
|---|---|---|---|
| Memory for each road, tiled path | about 16 KB (Tartu tiles) | 4 KB or less | the columnar model, dictionary text, no GeoJSON tree on the tiled path |
| Stockholm county (456,813 driving edges) in a browser tab | not possible (over MAX_ROADS) |
the first view in less than 10 s, peak less than 2.5 GB | the same, then measure and raise MAX_ROADS |
| Tartu style, native | 5.1 s | 2 s | the columnar model; parallel for the sources |
| One tile on request | 0.17 s (slowest, Burnaby) | 50 ms | the tile cache, the bytes of a value made once (rust-tile-speed) |
| WebAssembly against native | 1.7 times (levels) | 2 times or less in every stage | the same code; measure each stage |
Python render_edges on 50,000 edges, lonboard |
2.0 s for the style (vectorize_style_resolution.md) |
0.2 s | one Rust resolution for each distinct class key |
How¶
- Columns, not features.
RoadTablekeeps each property as one Arrow array, not a map per road. Text columns are dictionary arrays: one copy of each class name, color and road name. This is the open item of step 3 ("each text value is its own string"). - Geometry in one buffer. All coordinates are in one
Vec<[f64; 2]>with an offset for each road (the GeoArrow layout). The coordinates stayf64, for parity with Python. - No copy from Python. pyarrow gives the table through the Arrow C data interface (
arrow::pyarrow). geopandasto_arrow()gives WKB, and the reader decodes it once. - In the browser, one copy. The Parquet bytes go from duckdb-wasm into the module once. The worker keeps the tables, and the main thread gets only the style and the tiles.
- The tiled path makes no GeoJSON. The tile index reads the columns. Only
export_inlinewrites GeoJSON, and it streams it source by source (asmake_styledoes today). - Typed metadata and slots. The base map, the slots and the layer list stop being
serde_json::Valuetrees. The layer list stays JSON at the edge, when it is written. - Caches. The resolved look is cached by its hash. The tiles are cached by (source, z, x, y) with a byte limit (LRU). No other cache before a measurement asks for it.
- Threads. Native only, feature
parallel: the sources in parallel, the tiles in parallel. The solver stays single-thread (its stages depend on each other). - The browser limits. A wasm32 module has at most 4 GB of memory, and a tab can get less. The module must stay under
MAX_ROADS, and the error says to use a smaller area or the tiled path. The allocator grows the memory in large steps (16 to 256 MB), because each small step is slow in Edge on Windows.
The migration plan¶
Today and the target¶
flowchart LR
subgraph today["Today"]
direction TB
p1["Python: look resolution, data-driven color, legend,<br/>full mode, metre widths, decks, items, boundary,<br/>PMTiles, page, pages, CLI, folium, lonboard"]
r1["Rust (roadstyle-rs): simple-mode style,<br/>tiles on request, levels, worker SQL"]
j1["Page JS: web_template.html (1,688 lines)"]
end
subgraph target["Target"]
direction TB
r2["Rust core: model, look, engine, tiles, levels"]
r3["Rust adapters: io, wasm, py"]
j2["JS: roadstyle-dashboard"]
p2["Python: render_edges API, folium, lonboard,<br/>studio, MCP, snapshot, level editor"]
end
p1 -->|"phases 3 to 6"| r2
r1 -->|"phase 2: split"| r2
r1 -->|"phase 2"| r3
j1 -->|"task 2"| j2
p1 -->|"stays"| p2
Everything that makes a style or a tile moves to Rust. Python keeps its API and its notebook tools.
The phases¶
flowchart TB
P0["P0 Reference<br/>Python goldens, CI for cargo"]
P1["P1 Merge the work in progress<br/>tiles, tile speed, task 1, task 2"]
P2["P2 Crate split<br/>no change of output"]
P3["P3 Typed look in Rust<br/>task 3, look v1, schema"]
P4["P4 Columnar model, memory,<br/>missing style features"]
P5["P5 Python binding<br/>Python calls Rust part by part"]
P6["P6 Page and export in Rust<br/>page writer, PMTiles, npm"]
P7["P7 Python is a thin layer<br/>1.0"]
P0 --> P1 --> P2
P2 --> P3
P2 --> P4
P2 --> P5
P3 --> P6
P4 --> P6
P5 --> P7
P6 --> P7
After the crate split (P2), P3, P4 and P5 run at the same time in different crates.
P0: the reference¶
- Content. Commit
tests/make_goldens.py(fromrust-colors) as the one generator. It writes, from Python, the goldens for Monaco, Tartu and the Södermalm sample:export_inline(the style),export_style(style.json and the tiles),compute_levels(tags and solve) and the look. The golden files are gzipped. The large ones go to the GitHub pre-releaserust-test-data, and the repository keeps their SHA-256. A CI job runscargo test --offline,cargo clippyand the wasm32 build. - Golden test. The Rust output today against the Python goldens. Each difference is in a list, with a cause and a decision.
- Benchmark.
bench(frommain.rs) for Monaco and Tartu: time and peak memory of each stage, in a table in this file. - First user. The roadstyle-rs session.
- Exit. CI is green. The list of differences from Python is empty, or each item has a decision.
- Risk. glibc's
cosandhypotdiffer between machines (seetests/test_render_web.py:1898). Mitigation: the goldens come from one machine type (Linux x86_64), and the Rust code rounds where Python rounds (py_round).
P0: result (2026-10-23)¶
The goldens. rust/roadstyle-rs/tests/make_goldens.py is the one generator (Python roadstyle 0.24.3 from src/). For Monaco
(13,298 roads), the Södermalm sample (4,563 driving edges, with duckOSM's solved levels) and Tartu (64,828 roads) it writes the roads-only
style of export_inline, the tiles of export_style (the 3 x 3 tiles around the centre at zooms 12, 14 and 15) and the levels (the tags
rule; the solve with duckOSM's level-area defaults, not for Tartu: Python's solve needs too much memory there). The files over 1 MB and
Tartu's inputs are on the pre-release rust-test-data (goldens-1.tar, 42.5 MB); tests/get_goldens.sh gets them and checks their
SHA-256. The colour cases and the looks of tasks 1 and 3 come from the same script. tests/areas.rs compares the engine with them.
Differences found and fixed (the engine now does what Python does):
| Difference | Fix |
|---|---|
The slots wrote __rs_arw before __rs_cls |
Python's order (task 1) |
| A tile had the features within its buffer | tiles.py: a feature is in the tiles its box touches; the buffer is for the clipping only |
The ids of simple were 16 x edge + piece, the slots had none |
tiles.py: a feature's id is its index in its source |
| A tile kept only the properties the layers read | tiles.py's tile_props: every __rs_* property and keep; all the properties of a whole layer (the slots) |
| A line shorter than half a tile unit stayed at the last zoom | tiles.py: it is left out at every zoom |
After these fixes, every golden tile of the three areas has the same features, ids, properties and geometry as Python's (decoded), and the styles are the same below the top level, key order included, except the items below.
Differences that stay, each with its cause and a decision:
| Difference | Cause | Decision |
|---|---|---|
The nearest road of a slot: 4 slots of Monaco, 10 of Tartu, none of Södermalm (and 2 or 3 of them get a twin __rs_edge2 as a result) |
Each is the two directions of one way, with the same line: the slot's middle is at distance 0 from both. GEOS's STRtree.nearest takes the one its tree reaches first; the engine the lower index. "The higher index" does not match either (8 differ on Monaco then) |
Keep. The two edges are one way; the name and the arrow are the same. An exact copy needs GEOS's tree (its build and its search) |
| The solve of Monaco: 8 of 13,298 rows | Python's LP (OR-tools) and the engine's min-cost flow take different answers among equal best ones: both have 0 hard rules broken, 0 stack parts not lifted, 16 order wishes not kept, 9 numbers. Södermalm is equal in all rows | Keep: equal quality by roadstyle's own rules |
| The bytes of a tile | Python's encoder (mapbox_vector_tile) writes a negative integer as int_value and a float with no fraction as an integer; the engine writes sint_value and a double. MapLibre reads the same numbers |
Keep: the tests compare decoded tiles, not bytes |
| No Python solve golden for Tartu | Python's solve needs more memory than the machine can give | Keep: Monaco and Södermalm cover the solve; Tartu has the tags rule |
The base map in tiles (export_style): Python writes the points and polygons whole, the engine clips the polygons |
Not in the goldens (the export goldens have no base map) | Open: P4, with the base map in the export goldens |
The benchmark (main.rs --bench, native release build, 2026-10-23):
| Stage | Monaco: ms | in use after, MB | peak in it, MB | Tartu: ms | in use after, MB | peak in it, MB |
|---|---|---|---|---|---|---|
| Read the roads | 43 | 38 | 41 | 210 | 185 | 198 |
| Mark the roads (colours, pairs, levels) | 152 | 74 | 82 | 825 | 361 | 404 |
Pieces (simple) |
85 | 126 | 128 | 508 | 711 | 719 |
Names and arrows (slots) |
111 | 134 | 138 | 658 | 764 | 788 |
| Base map | 24 | 143 | 145 | 196 | 871 | 881 |
| Roads source | 8 | 143 | 144 | 25 | 871 | 874 |
| Layers | 58 | 145 | 149 | 83 | 874 | 887 |
| The text of the sources | 39 | 177 | 177 | 225 | 1,134 | 1,134 |
Whole process (/usr/bin/time) |
550 | 194 (resident) | 2,890 | 1,128 (resident) |
CI. .github/workflows/rust.yml: on each push to main or rust-engine that changes rust/ or roadstyle's data, and on pull
requests: the goldens from the pre-release, cargo test --offline --release, cargo clippy -D warnings, the wasm32 build. Each night
and by hand: the ignored Tartu goldens. Clippy: the .into() calls of &str are gone; needless_range_loop and type_complexity are
allowed in the crate (ported numeric code).
P1: merge the work in progress¶
- Content.
rust-tile-speed, thenrust-tiles(they conflict intiles.rs), task 1 (rust-colors), task 2 (the dashboard module). duckosm-rust and geolibre-roadstyle pin the new commit and delete their copies oflook.jsonand of the SQL. - Golden test. The tiles against Python's
tiles.pyoutput (from P0). Task 1 againstexport_inlinewith each data keyword. Task 2: the 24 screenshots ofstyle_config.mdare the same. - Exit. One branch
rust-enginewith all four. The two Rust users build from it. - Risk. Three branches edit
rust/roadstyle-rs/src. Merge one at a time, in the order above.
P2: the crate split¶
- Content. A move of files, with no change of behavior: the crates of The crates.
Result<_, String>becomes the error enums.main.rsbecomesexamples/bench.rs. - Golden test. The same hashes as before the move.
- Benchmark. The wasm module size before and after (no growth of more than 2 %).
- Exit.
cargo build --target wasm32-unknown-unknown -p roadstyle --no-default-featurespasses. duckosm-core depends onroadstyle-levelsonly. - Risk. It touches every file in
rust/. It must run when no other branch editsrust/: a freeze of one day, after P1.
P3: the typed look in Rust (task 3)¶
- Content.
roadstyle-look: the structs, the bundled files,Look::resolve, the schema. A reader of today'slook.json(version 0), so the users can move one at a time.resolveLookinroadstyle-wasm. Thepaletteview row (_merge_palettes) in Rust. The output side of the interaction contract:roadstyle:contract,roadstyle:controls,roadstyle:state, the layer roles, andstate: live(the global state kept in the expressions). The inventory of shared variables moves todocs/reference/contract.md, withtests/test_contract_names.py. - Golden test. For 4 palettes x 2 profiles x the settings fixtures of
tests/test_settings_overrides.py, the look from Rust is equal to the look from Python. The style from the Rust look is equal to the style from the Python look. - First user. The GeoLibre plugin:
build.pystops running Python and mapstyle to make the 64 looks. - Exit. No user reads Python's internal tables (
render_web.CONFIG,ROAD_Z,_settings.theme()) to make a look. - Risk. mapstyle changes the settings by mode (
mode_settings). Its looks go into the golden set.
P4: the columnar model, memory, and the missing style features¶
- Content.
RoadTableover Arrow, dictionary text, typed slots and base map, the column map and the presets (highway_col,edge_id_col,tunnel_col,bridge_col,layer_col,cap_col), the readers of the format table. The render model:RoadDraw,LevelPlan(withfixedroads and the edit rows inroadstyle-levels),Attachmentfor the slots,items,Overlayfrom user data andedge_attach,DrawKeyand the one sort-key function. Then the style features that only Python has: metre widths (width_m_col,width_m_zoom,casing_m,casing_min_px), 3D bridge decks (view_3d),boundary,access_colcolors,includeandexclude, levels computed when no level columns are given. - Golden test. The hashes do not change for the model work. Each new feature gets its golden from Python.
- Benchmark. The targets of Efficiency. Measure Stockholm county in Chrome and Edge, then set
MAX_ROADS. - First user. duckosm-rust's page (larger areas).
- Risk. The model work and the feature work both edit
roadstyle-engine. The model API comes first, in one small change.
P5: the Python binding¶
State (2026-10-23): step 1 is done. rust/roadstyle-py makes roadstyle._rust (make_style, make_look, Tiler; abi3, the GIL is free
while Rust works). roadstyle._engine has the switch, and export_inline(engine="rust") gives the same style as Python for the 7 cases of
tests/test_engine_parity.py (Rust 0.12 s, Python 0.57 s on 1,373 roads). compute_levels(engine="rust") and
export_style(engine="rust", format="xyz") are done too: the same style.json byte for byte, the same files, and each tile with the same features when
decoded (a number can be an integer in one engine and a float in the other). render_edges(engine="rust") is done too: the Rust engine makes
the live style (args.state = "live") and Python writes the page with the same page writer (render_web._page_html); the page is the same
byte for byte (tiled pages and export_style(format="pmtiles") too, with the Rust tiles; Tartu: 19.1 s with Python and 6.2 s with
Rust, tiled 147.5 s and 12.2 s). The output side of the P3 contract is in it: roadstyle:contract, roadstyle:state and roadstyle:controls (the options of
rsDashboard from the style, checked against render(_parts=True)), and the dashboard reads them. Next: the wheels with maturin, the typed
look (P3), the layer roles, docs/reference/contract.md.
- Content.
roadstyle-py(PyO3, maturin, abi3 wheels for Linux x86_64 and aarch64, macOS, Windows). Python calls Rust part by part, in this order: the style ofexport_inline, the tiles (tiles.py), the levels (compute_levels), the look (_settings.theme()), the data stylers. An explicit switchengine="python"orengine="rust"(alsoROADSTYLE_ENGINE) selects the engine. A part that Rust does not have gives an error withengine="rust". It does not change to Python without a message. - Golden test. The full pytest suite runs two times, one time with each engine. A new test,
tests/test_engine_parity.py, compares the two outputs byte for byte for each fixture and each keyword in the parity checklist. - Benchmark.
render_edgeson 5,000, 50,000 and 456,813 edges, with each engine. - First user. SonoFlow (nightly pages).
- Risk. Wheels for each platform make the release longer. maturin's GitHub action builds them.
P6: the page and the export in Rust¶
State (2026-10-23): the page writer is done (rust/roadstyle/src/page.rs, make_page in the Python binding, makePage in the browser
package): the pages of tests/test_engine_parity.py are byte for byte the Python pages. export_style (XYZ and PMTiles) is done through
the binding, and the crate writes the PMTiles archive (roadstyle_tiles::pmtiles, Tiler.archive). Open: the npm packages,
the runtime API (getState, setState, the events), Tiler.restyle, the sidebars of render_dashboard and render_report. The crate
reads the static files from src/roadstyle/ when it is built; a crates.io package needs them in the crate (a build step copies them).
- Content. The page writer in
roadstyle-io(featurepage): the HTML frame, the dashboard bundle, the style, the page options, the gzip of the sources (compress), the host panel.export_style(style.json, XYZ tiles, PMTiles, sprite). The three page sidebars (dashboard.html,report.html,street_view.html) as static files. The npm packages. The runtime API of the interaction contract: thersobject,getStateandsetState, the events, the aliases of the old names, the engine port andTiler.restyle. - Golden test. The page from Rust is equal to the page from Python, for the reference pages of
docs/build_maps.py. - First user. mapstyle (
export_style,render_report). - Exit.
render_web.renderonly converts its keywords and calls Rust.
P7: Python is a thin layer¶
- Content. Remove the Python code of each part that is at parity for one minor version with no difference. scipy, ortools, mapbox-vector-tile and pmtiles leave the dependencies. Version 1.0.
- Exit. Python roadstyle has the API, folium, lonboard and the tools. The Rust goldens become the reference.
Parallel work without two sessions in one file¶
| Lane | Phase | Owns these files | Waits for |
|---|---|---|---|
| A: reference and CI | P0 | tests/make_goldens.py, rust/*/tests/, .github/workflows/rust.yml |
|
| B: work in progress | P1 | the branches of each task, as today | A for the goldens |
| C: the look | P3 | rust/roadstyle-look/, rust/roadstyle-wasm/src/look.rs |
P2 |
| D: model, render model and memory | P4 | rust/roadstyle-model/, rust/roadstyle-io/, rust/roadstyle-levels/ (fixed roads, edits) |
P2 |
| E: style features | P4 | rust/roadstyle-engine/ |
D for the model API |
| F: Python binding | P5 | rust/roadstyle-py/, src/roadstyle/_engine.py, tests/test_engine_parity.py |
P2 |
| G: page, export and the runtime API | P6 | rust/roadstyle-io/src/page.rs, rust/roadstyle-tiles/src/pmtiles.rs, src/roadstyle/static/dashboard/, rust/roadstyle-wasm/src/restyle.rs |
task 2, C |
Rules:
- One session for each lane. A lane edits only its own files.
- A change of the API of another crate is a small change of its own, reviewed first, and the other lane rebases on it.
- Each lane works on a branch from
origin/main(or fromrust-engineuntil P1 is merged), and checksgit log origin/main..<branch>before a push. - Nothing goes to
main, PyPI, crates.io or npm before a person tries the pages and gives an OK.
The parity checklist¶
States: done (Rust makes the same output today), look (Rust reads the value from look.json, which Python makes;
the resolution moves in P3), task 1, task 2, task 3 (in progress), Pn (planned in phase n), check (not known:
the P0 golden run decides), Python (stays in Python), internal (not ported).
render_web.render: 76 parameters¶
| # | Parameter | State | Note |
|---|---|---|---|
| 1 | gdf |
done for GeoParquet; P5 for a GeoDataFrame | |
| 2 | palette |
look; task 3 | |
| 3 | highway_col |
done | the engine's column map (ColumnMap): the user's names in the data and the road layers |
| 4 | filter_col |
done | |
| 5 | styler |
P5 | a Python object; the binding converts it |
| 6 | basemap |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 7 | basemaps |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 8 | name |
done | |
| 9 | offset_frac |
look | |
| 10 | width_frac |
look | |
| 11 | offset_zoom |
done | the same style as Python (tests/test_engine_parity.py) |
| 12 | tunnel_col |
done | the engine's column map (ColumnMap): the user's names in the data and the road layers |
| 13 | bridge_col |
done | the engine's column map (ColumnMap): the user's names in the data and the road layers |
| 14 | layer_col |
done | the engine's column map (ColumnMap): the user's names in the data and the road layers |
| 15 | edge_id_col |
done | the engine's column map (ColumnMap): the user's names in the data and the road layers |
| 16 | road_fill |
done | the same style as Python (tests/test_engine_parity.py) |
| 17 | cap_col |
done | the same style as Python (tests/test_engine_parity.py) |
| 18 | cap_start_col |
done | |
| 19 | cap_end_col |
done | |
| 20 | casing_level_col |
done | with stored levels; without them the engine computes them, as render (compute_levels, solve) |
| 21 | fill_level_col |
done | as 20 |
| 22 | casing_start_col |
done | |
| 23 | casing_end_col |
done | |
| 24 | head_m |
done | the same style as Python (tests/test_engine_parity.py) |
| 25 | head_start_m_col |
done | |
| 26 | head_end_m_col |
done | |
| 27 | directed_col |
done | |
| 28 | driving_col |
done | |
| 29 | width_m_col |
done | the same style as Python (tests/test_engine_parity.py), with the mitre ends |
| 30 | width_m_zoom |
done | the same style as Python (tests/test_engine_parity.py), with the mitre ends |
| 31 | casing_m |
done | the same style as Python (tests/test_engine_parity.py), with the mitre ends |
| 32 | casing_min_px |
done | the same style as Python (tests/test_engine_parity.py), with the mitre ends |
| 33 | pitch |
done | the export gives the same style as Python; the page: the dashboard module |
| 34 | bearing |
done | the export gives the same style as Python; the page: the dashboard module |
| 35 | max_pitch |
done | the export gives the same style as Python; the page: the dashboard module |
| 36 | view_3d |
P4 | no bridge decks in Rust (building extrusion is done) |
| 37 | arrows |
done | the same style as Python (tests/test_engine_parity.py) |
| 38 | labels |
done | the same style as Python (tests/test_engine_parity.py) |
| 39 | filter_control |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 40 | find_control |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 41 | overlay_control |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 42 | overlay_popup |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 43 | access_col |
done | the same style as Python (tests/test_engine_parity.py) |
| 44 | side_panel_html |
P6 | the page writer |
| 45 | host_panel_width |
P6 | |
| 46 | basemap_switcher |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 47 | zoom_readout |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 48 | road_popup |
done | the same style as Python (tests/test_engine_parity.py) |
| 49 | road_tooltip |
done | the same style as Python (tests/test_engine_parity.py) |
| 50 | hover_delay_ms |
done | the same style as Python (tests/test_engine_parity.py) |
| 51 | data_maxzoom |
done | the same style as Python (tests/test_engine_parity.py) |
| 52 | data_tolerance |
done | the same style as Python (tests/test_engine_parity.py) |
| 53 | popup_mode |
done | the export gives the same style as Python; the page: the dashboard module |
| 54 | street_view |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 55 | street_view_key |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 56 | google_3d |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 57 | tooltip |
done | the alias of road_tooltip |
| 58 | hover_color |
done | the overlays too |
| 59 | select_color |
done | the overlays too |
| 60 | boundary |
done | the same style as Python (tests/test_engine_parity.py); the binding converts shapely and GeoPandas |
| 61 | color_options |
done | task 1 (colors.rs); the same style as Python |
| 62 | color_active |
done | task 1 (colors.rs); the same style as Python |
| 63 | views |
P3 (palette rows); task 2 (menu) | |
| 64 | overlays |
done for base-map overlays and edge_attach; P4 for Overlay from user data |
|
| 65 | items |
P4 | |
| 66 | items_popup |
P4; task 2 | |
| 67 | compress |
P6 | |
| 68 | tunnel_control |
done | the dashboard module (task 2, src/roadstyle/static/dashboard/) |
| 69 | tiles |
done (tiles on request); P6 (PMTiles in the page) | |
| 70 | simple |
done for True; False is not ported (decision 2) |
|
| 71 | minzoom |
done | the same style as Python (tests/test_engine_parity.py) |
| 72 | legend |
done | task 1 (colors.rs); the same style as Python |
| 73 | api_key |
P6 | the base-map URL |
| 74 | _edges |
Python | the level editor's update in place |
| 75 | _palette_copy |
internal | replaced by the Rust palette merge (P3) |
| 76 | _export |
internal | the Rust export is the API |
render_edges: 21 parameters¶
| # | Parameter | State | Note |
|---|---|---|---|
| 1 | gdf |
P5 | |
| 2 | backend |
Python | "web" calls Rust; "folium" and "lonboard" stay |
| 3 | palette |
task 3 | |
| 4 | highway_col |
P4 | |
| 5 | include |
P4 | |
| 6 | exclude |
P4 | |
| 7 | match_links |
P4 | |
| 8 | style |
P5 | |
| 9 | color_by |
task 1 | |
| 10 | colors |
task 1 | |
| 11 | cmap |
task 1 | cmaps.json: branca and matplotlib ramps at 9 stops |
| 12 | vmin |
task 1 | |
| 13 | vmax |
task 1 | |
| 14 | width_by |
task 1 | |
| 15 | color_table |
task 1 | |
| 16 | color_key |
task 1 | |
| 17 | color_col |
task 1 | |
| 18 | settings |
task 3 | |
| 19 | profile |
task 3 | |
| 20 | api_key |
P6 | |
| 21 | levels_db |
P5 | the DuckDB reader (feature duckdb) |
The pages, the export, the CLI and the other API¶
| Item | State | Note |
|---|---|---|
render_dashboard, render_report, render_street_view, sidebar_html |
P6 | the sidebars are static files on the rs* API |
export_inline |
done | make_style |
export_style (XYZ, PMTiles, sprite, popup files) |
XYZ done (Tiler); PMTiles P6 |
|
export_look |
P3 | |
compute_levels(method="tags") |
done | equal rows to duckosm-rust; P5 connects it to Python |
compute_levels(method="solve"), level_input, solve_levels |
done for the default call; check for edits, fixed, max_positions |
Tartu: 64,828 of 64,828 rows equal to roadstyle (duckosm-rust tools/compare_levels.py) |
auto_ends, casing_parts |
check | |
save_levels, load_levels, stored_levels, area levels |
Python; P5 for the DuckDB reader | |
level_area (roadstyle-levels make, solve), level_editor (edit) |
Python | they call the Rust solver after P5 |
use_settings, palettes API (register_palette, load_palette, save_palette, palette_to_dict, palette_from_dict) |
task 3 | |
Basemaps (BASEMAPS, get_basemap, register_basemap, set_api_key) |
P3 | the list moves into the look |
Stylers (ClassStyler, CategoricalStyler, NumericStyler, ColorTableStyler, build_styler, color_by*) |
task 1 (the style); P5 (the Python classes call Rust) | |
resolve, base_style, selection_style, normalize_highway |
P3 | |
Edges (RoadEdges, as_edges, load_edges, from_geojson, from_arrow, from_duckdb, is_directed) |
Python front-end; P4 for the readers | |
Overlay |
Python front-end; P4 for the engine | |
to_spec, to_geojson, to_html, to_iframe, save, save_spec, load_spec |
Python | the Leaflet output |
snapshot, make_legend, BaseLayerSwitcher |
Python | |
roadstyle CLI (input, -o, -f web/folium/rsjs/spec/geojson, --palette, --basemap, --tooltip, --include, --exclude, --color-by, --colors, --cmap, --vmin, --vmax, --width-by, --page, --panel-width, --no-resize, --layout, --view-3d, --pitch, --bearing, --no-arrows, --no-labels, --no-filter, --no-basemap-switcher, --tiles, --no-compress, --highway-col, --layer) |
Python | it calls Rust through render_edges |
roadstyle studio, roadstyle-mcp |
Python | |
The window.rs* API (29 functions) and the rs:* events |
task 2 (the module keeps each name); P6 (the rs object, the old names as aliases for one minor version) |
Quality¶
Tests¶
| Kind | What | Where |
|---|---|---|
| Unit | Each crate: the expression builders, the merge of the look, the MVT encoder, the solver stages | rust/<crate>/src (#[cfg(test)]) |
| Golden | The Rust output against the Python output, byte for byte, for Monaco, Tartu and Södermalm, for each keyword of the parity checklist | rust/roadstyle/tests/, generator tests/make_goldens.py |
| Property | proptest: a tile decodes to the input geometry within half a tile unit; the levels keep each casing under the fill of each higher road that crosses it; the merge of the look is the same for any split of one settings source into two |
rust/<crate>/tests/ |
| Contract names | Each rs_*, __rs_*, roadstyle:*, localStorage key and --rs-* variable in the code has a row in docs/reference/contract.md, and each row has a name in the code |
tests/test_contract_names.py |
| Cross-language | The same fixture through native Rust, WebAssembly (Node, wasm-bindgen-test) and the Python binding gives the same SHA-256 |
CI |
| Browser | The page in Chromium with Playwright: the layers load, a click shows the popup, the controls work (as tests/test_web_smoke.py does today) |
tests/ |
Benchmarks¶
examples/bench.rs: each stage (read, look, sources, layers, tiles) with its time and the peak memory, for Monaco, Tartu and Burnaby. The results go into a table in this file at each release.criterionfor the hot functions: the tile encoder, the expression builders, the solver.- CI records the peak memory of Monaco and fails when it increases by more than 10 %. CI records the time but does not fail on it, because the GitHub runners are not steady.
CI (GitHub Actions)¶
| Job | When | Steps |
|---|---|---|
rust |
each push | cargo fmt --check, cargo clippy -D warnings, cargo test --offline, the wasm32 build with no default features, the size of the module |
parity |
each push | pytest with engine="rust", tests/test_engine_parity.py |
tartu |
each night | the --ignored goldens with the inputs from the pre-release |
release |
a tag | crates.io (in the order model, look, levels, engine, tiles, io, roadstyle), PyPI wheels (maturin), npm |
Docs (Diátaxis)¶
| Kind | Pages |
|---|---|
| Tutorial | "Your first map from Rust"; "Your first map in a browser worker" |
| How-to | Use roadstyle in a worker; Use your own palette or profile; Show a large area with tiles; Use the Rust engine from Python |
| Reference | rustdoc for each crate; the look schema (look.schema.json) as a table; the JS API; docs/reference/parameters.md made from the parity checklist |
| Explanation | This file; rust_engine.md; levels_split_casing.md |
Examples¶
rust/roadstyle/examples/: style.rs (a GeoParquet file to style.json), tiles.rs (a tile server on request), bench.rs.
examples/dashboard_rust/ (task 2): a page on roadstyle-wasm and roadstyle-dashboard.
Decisions¶
Decisions 1 to 8 were agreed on 2026-10-23, each as recommended. Decision 9 is open; the recommendation is first.
| # | Decision | Decided (2026-10-23) | Not chosen |
|---|---|---|---|
| 1 | The crates | The nine crates of The crates | Keep one crate with feature flags |
| 2 | Full mode (simple=False) |
It is not ported. Its one user (SonoFlow viz/daily.py:179) needs "change the color of each road from a property in the page": the dashboard module gets that call, and full mode is retired |
Port full mode to Rust (more layers, more goldens) |
| 3 | The look file | Look v1: typed and versioned; the engine reads today's file as version 0 for one minor version | Keep today's file |
| 4 | Unknown keywords | A warning for one minor version, then an error (today **kwargs ignores them; see below) |
An error at once |
| 5 | The Python package | One wheel roadstyle with the engine in it (maturin, abi3) |
A pure Python roadstyle and a separate roadstyle-engine wheel |
| 6 | npm | Publish roadstyle-wasm and roadstyle-dashboard after P3 |
Users keep building from a commit |
| 7 | Versions | One version for all crates and packages, the same as Python roadstyle | A version for each crate |
| 8 | New behavior of the render model (placement along an edge with side and offset, the split of the merged layer, an icon kind, a color by a column as an expression) | Made once, in Rust, after P5; Python gets it through the binding. Its golden is the Rust output after a review of the pages | Make it in Python first (the reference), then port it |
| # | Open decision | Recommendation | The other choice |
|---|---|---|---|
| 9 | The runtime API of the dashboard | One typed rs object from rsDashboard, with getState, setState and on(); the window.rs* names as aliases for one minor version |
Keep the window.rs* functions as the API |
Found during the inventory¶
These are not part of the plan. Each one is a separate fix.
theme=is ignored without a message.render_edgeshas nothemekeyword since 0.24.0 (style_config.md), andrender_web.render(**_ignore)drops it. SonoFlow passes it (viz/chains.py:124,viz/daily.py:168), and the scan counts 28 calls in 8 projects.__HOVER_COLOR__is replaced inrender_web.py:3784, butweb_template.htmlhas no such placeholder.docs/design/style_config.mdstill namesdata/themes/default.yaml. The files aredata/profiles/anddata/palettes/.docs/reference/parameters.mdshows defaults that the code resolves from the profile (offset_frac0.28,cmap"viridis",pitch0) as if they were the keyword defaults (None).- The Burnaby style time (6.2 s) of the brief is not in any file of the repository. P0 measures it again.
- A stored level row that is missing becomes 0 in the browser.
roads_sql.jsjoinsvisualization.edge_levelswith aLEFT JOIN, andmark_levelsreads null as 0. Python'slevels_dbstops with an error for the same case. - Two fill numbers in Rust's
edge_attach. It picks the edge on top by the rawfill_levelcolumn (overlays.rs:50), butedge_overlaydraws the object at__rs_fl, the number after the swap and the lowering. The two can differ when the input has a casing number above its fill number. - Superseded design notes.
levels_and_looks.md(the three bands) anddraw_order_per_edge.md(band_col,order_col,line-sort-key = lvl * 1000 + order) describe rules thatlevels_split_casing.mdsection 12 removed.renderrejectsband_colandorder(render_web.py:2859).