The tile contract: tiles draw, the data layer knows¶
Status: design, 2026-10-27; the tile size standard (below) is in force with its test and budget; rules 6, 7 and 9 for the base map: 2026-10-28.
The rest has no library code yet.
Branch design-tile-contract, from rust-engine at cfa80c2.
This note separates the data of a road from its tiles. A tile then has only what MapLibre needs to draw it. All other data stays in the map worker and the page gets it by id. The note gives the contract, the id, how the tile keys come from the style, the data layer API, the CI checks, the expressions and a plan in phases. The decisions for Kaveh are in the last section.
Measurements: Burnaby, the tilted view (pitch 60, zoom 14), headless Chromium with software GL. Files: .claude/tile-lag/ of the
workspace (RESULT.md, ANALYSIS.md, one-feature/, expr/) and tile_speed.md.
The tile size standard¶
The tile algorithm applies every reduction that keeps the map the same. If a tile is still large after that, no reduction is left, and the tile is as small as it can be. So the standard is a list of reduction rules, not a size limit. Each rule has a status and a test. A ratchet keeps the numbers from getting worse. A change that adds a feature to a tile, or a key, or a layer, must say which rule allows it.
The reduction rules. Each rule has a number in the budget (per tile layer, the largest tile of the view). A rule that is done has the number 0, and the test asserts it. For the other rules, the number can only go down.
| # | Rule | Now | The test |
|---|---|---|---|
| 1 | Only the features that a style layer draws at a zoom of the tile (ZoomRules::span) |
Done where every layer on the source has a zoom rule (simple: 17 % of its features out at z13). Not done where a layer draws the source at every zoom: roads-highlight keeps all of roads (full look). |
undrawn = 0 for a layer with a zoom rule (asserted) |
| 2 | Only the keys that the style reads (section 3) | Not done. Unread keys: simple 3 of 18, slots 6 of 12, roads 14 of 21 (full look). |
keys_unread (ratchet) |
| 3 | The id is the MVT feature id, a dense row, and no id key (section 2) | Not done. simple has no MVT id (__rs_edge and __rs_edge2 are keys). |
no_id (ratchet) |
| 4 | A number, not a text | Not done. simple has 19 to 33 text values (class, colours). roads has 30,302 in one Monaco tile (509,422 bytes). |
strings, string_bytes (ratchet) |
| 5 | Each key and each value once in a layer | Done. | duplicates = 0 (asserted) |
| 6 | Simplify a line to the pixel tolerance of the zoom | Partly. Below the last zoom the tolerance of a line is 0.5 of 4096 units (1/16 px). A tolerance of 1 px gave 1 to 2 % on Burnaby (tile_speed.md). The polygons of a flat layer (the base map's land, water and paved areas) are done (2026-10-28): cut to the tile and its buffer and simplified to 1 unit (1/8 px) below the last zoom: Burnaby z10 land 262 KB to 147 KB. | short_segments, segments under 1 px (ratchet; monaco-base, tartu-base); the unit test a_flat_layers_polygon_is_cut_to_the_tile_and_simplified |
| 7 | Drop or merge a feature smaller than a pixel at the zoom | Partly. Done below the last zoom (rule B, 2026-10-27): a road whose pieces are together under 1 px goes unless it leaves the tile, and a casing end piece (__rs_seam) under 1 px goes. Every fill and every casing body piece stays: a rule that dropped all pieces under 1 px broke streets. tiny in Monaco z12: 1,564 → 169. A base-map line of a flat layer that is together under half a pixel long and stays in the tile is done (2026-10-28): Burnaby z10 other ways 384 KB to 208 KB. A polygon under 1 px was tried and left out: 2 to 4 % of the bytes, no change in MapLibre time. Not done: a merge of equal pieces, and the tiles of the last zoom. |
tiny (ratchet); the unit tests of drop_tiny and a_flat_layers_line_under_half_a_pixel_is_left_out |
| 8 | Coordinates on the grid of the zoom | Not done. All zooms use 4096 units (8 for each pixel); z12 needs 2. The gain is small: geometry is 11 % of roads and 18 % of simple. |
bytes (ratchet); a test of the grid comes with the rule |
| 9 | A base-map layer follows rules 1 to 8 | Partly. It follows rule 1 by its minzoom (no gain on Burnaby: undrawn is 0 for every layer that the page shows). Rules 6 and 7 are done for a flat layer. All its keys stay (ov4: 0.3 MB of osm_id, kind, name). |
The areas monaco-base and tartu-base of the budget (the page's look with the base map). |
| 10 | One copy of a road in the tile source | Done in the page's look. Not in the full look: roads repeats each road that simple has. |
The roads layer of monaco-full and tartu-full (ratchet) |
What the big tiles are. The full look (export_style, for a server) has a second road layer, roads: one feature for each whole
road with 21 columns. In the largest z12 tile it is 1,366,746 of 1,515,563 bytes in Monaco and 3,740,748 of 3,984,675 in Tartu. The
page's look has no roads layer, and its largest tile in these views is 383,687 bytes (Monaco z15).
The roads layer, largest z12 tile |
Monaco | Tartu |
|---|---|---|
| Bytes | 1,366,742 | 3,740,743 |
| Geometry | 146,342 (11 %) | 392,562 (10 %) |
| Tags | 493,455 (36 %) | 1,369,051 (37 %) |
| Value table | 635,312 (46 %) | 1,702,620 (46 %) |
edge_id, edge_ref, __rs_edge, osm_id, __rs_edge2 (tags and values) |
893,581 (65 %) | 2,424,304 (65 %) |
| Features | 13,078 | 35,672 |
Features that simple draws at z12 (its fill pieces) |
1,850 (14 %) | 3,035 (9 %) |
| Features under 1 px | 8,078 (62 %) | 12,163 (34 %) |
The missing rules, in the order of their effect on these tiles:
- Rule 1.
roads-highlight(the glow of a hovered road) has no zoom rule, soroadskeeps every road at z12, also those thatsimplehides (86 % of the features in Monaco, 91 % in Tartu).roads-fillhas a class minzoom, but its opacity is 0: it is only a click target. If the glow readssimplewith feature-state, as the page's look does, no layer needsroads. - Rules 2 and 4.
edge_id(a 64-bit hash),edge_ref,osm_idand__rs_edge2are popup data, and 65 % of the layer. They are not read by the style, and most are text. They belong to the data layer (a props file by id for a server). - Rule 10. With 1 and 2 done,
roadshas nothing thatsimpledoes not have, so the layer goes.
In the page's look, the missing rules are 3 and 2, and the rest of 7 (a merge).
The ratchet. The budget file rust/roadstyle-rs/tests/data/tile_budget.json has a limit for each number. The views are the 3 x 3
tiles around a fixed centre of Monaco and of Tartu, at zoom 9 to 15 (12 to 15 before 2026-10-28), with an empty keep list (the engine's own tiles,
without a page's popup fields). Each area has three looks: monaco and tartu are the page's look (the lean look: the simple
pieces and the slots), monaco-full and tartu-full are the look of export_style, and monaco-base and tartu-base are the page's look
with the area's base map (land, water, paved areas, buildings).
| Number | For |
|---|---|
features, bytes, values (the distinct values), keys, property_bytes (the bytes of the features' tags fields), and the numbers of rules 2, 3, 4, 6 and 7 |
the largest tile of each tile layer, in each view |
| bytes of all the tiles, bytes and features of the largest tile | each view |
| layers, characters of all expressions | the style |
| characters of each filter, paint and layout over 256 | each style layer |
The protocol.
- A change that makes a number larger than its limit, or breaks a done rule, fails CI. The Rust test
tile_budgetchecks the four looks and the style.tests/test_tile_budget.pychecks that the Python binding gives Monaco's page tiles within the same limits. - To lower the limits after a change that makes the tiles smaller, run
scripts/tile_budget.pyand commit the file. The script lowers a limit and never raises one. A limit is the measured number plus 2 % (at least 1). - To raise a limit or to add one (a new layer, a new expression over 256 characters), run
scripts/tile_budget.py --raise --why "the reason". The script writes the new limits and the reason in the file (raises). The PR text must give the same reason, with the numbers before and after, and Kaveh must agree before the merge. Do not edit a limit by hand. - A new rule, or a rule that gets done, changes this table in the same PR: when a number reaches 0, the test asserts it from then on.
- The local Burnaby gate prints the same table for the Burnaby roads, with the limits of
areas.burnabywhen the file has them:ROADSTYLE_BURNABY_ROADS=/abs/roads.parquet cargo test --release -p roadstyle-rs --test tile_budget burnaby -- --ignored --nocapture(more inputs:ROADSTYLE_BURNABY_LOOK,ROADSTYLE_BURNABY_FEATURES,ROADSTYLE_BURNABY_VIEWS; use absolute paths). The Burnaby roads are not in the repository, so CI does not run it.
The budget now (2026-10-27; each limit in the file is this number plus 2 %). The page's look:
| Area | Zoom | Bytes, 9 tiles | Largest tile: bytes | features | simple: features |
bytes | values |
|---|---|---|---|---|---|---|---|
| monaco | 12 | 148,817 | 148,817 | 3,057 | 3,057 | 148,817 | 4,496 |
| monaco | 13 | 328,297 | 188,682 | 3,930 | 3,930 | 188,682 | 5,109 |
| monaco | 14 | 422,576 | 261,434 | 5,092 | 4,243 | 209,696 | 5,530 |
| monaco | 15 | 1,189,927 | 383,687 | 7,384 | 6,605 | 333,935 | 8,806 |
| tartu | 12 | 471,606 | 243,927 | 5,366 | 5,366 | 243,927 | 6,362 |
| tartu | 13 | 1,200,903 | 280,126 | 5,778 | 5,778 | 280,126 | 7,051 |
| tartu | 14 | 1,101,318 | 184,791 | 3,710 | 3,155 | 152,719 | 3,958 |
| tartu | 15 | 785,237 | 167,383 | 3,348 | 3,018 | 146,333 | 3,058 |
The full look of the same views is larger because of roads (largest tile: Monaco 1,515,563 / 1,174,723 / 1,200,631 / 906,307 bytes at
z12 to z15; Tartu 3,984,675 / 2,008,851 / 846,441 / 340,709). The style (the same for both areas) has 5 layers in the full look, 4 in
the page's, and the page's roads-simple line-width is 25,796 characters (section 6).
Why¶
On Burnaby, the tilted view takes 2.15 s from the jump to an idle map. tile() in the worker is 0.35 s of it. MapLibre's parse and
layout of the 30 tiles (5.9 MB, 115k features) is 1.4 s. In the road layer simple, the properties are 2.19 MB and the lines are
0.75 MB. Four keys of each road piece (__rs_edge2, __rs_cls, lvl, __rs_access) go only to the dashboard, not to the style.
The sort key __rs_s has a different value on almost each piece (49k values).
Two facts from the measurements decide the design:
- MapLibre's time follows the features that it draws and the data-driven expressions that it calculates for them. A feature that
no layer draws costs little (option A, now in
rust-engine). - The road layer's expressions are most of the road cost. With all the paint and layout values of the road layer made constant,
the view takes 0.77 s, not 1.42 s, and
line-widthalone is 0.38 s (theexpr/test, section 6). Smaller tiles alone give less.
The contract therefore has two parts: small tiles, and small expressions.
1. The contract¶
A feature in a tile has these items only:
- its geometry, clipped to the tile and its buffer;
- its id: the MVT feature id, a small number (section 2);
- the keys that a style layer on its tile layer reads at the zoom of the tile (section 3).
All other data is in the data layer: the road table and the feature tables in the map worker, with the id as their row. The page gets a value by id from the worker. The page never reads a property of a tile feature that the style does not read.
flowchart LR
subgraph W["Map worker (Rust, wasm)"]
RT["Road table<br/>row → fields, line, edge_id"]
FT["Feature tables<br/>ov0 … ov14: row → fields"]
ID["Id map<br/>row ↔ edge_id (BIGINT)"]
T["tile(z, x, y)<br/>geometry + id + style keys"]
RT --> T
FT --> T
end
subgraph P["Page (main thread)"]
ML["MapLibre style<br/>filter, paint, layout"]
UI["Popup, side panel,<br/>dashboard, editor"]
end
T -- "MVT bytes" --> ML
UI -- "rows, edge_ids, select(where)" --> W
W -- "fields, row lists" --> UI
UI -- "feature-state by id<br/>(live colour, width, hover, select)" --> ML
UI -- "filter: id list" --> ML
Who reads what:
| Reader | Reads from the tile | Reads from the data layer | How it reaches the map |
|---|---|---|---|
| Style (filter, paint, layout) | the style keys, ["id"] |
nothing | |
| Popup, tooltip | the id of the feature under the pointer | fields(rows, names) |
|
| Side panel, Street View, Google 3D | the id | the fields, roadLines(rows) |
|
Dashboard filters (class, access, bridges, tunnels, modes, rsFilter) |
nothing | select(where) gives the rows that show |
a filter clause on ["id"] |
Live colours and widths (rs.setData) |
nothing | rowsOf(edgeIds) for the join |
feature-state rs_c, rs_w by id |
| Hover and selection glow | the id | the twin row for a pair | feature-state hover, select by id |
| Level editor | the id | the road table (RS_SIDE), relevel |
new tiles of the changed roads |
Base-map popup (ov4: osm_id, kind, name) |
the id | features(layer, rows, names) |
Much of this is already true. The popup and the side panel read RoadTable (core.js). The live colours and the glow are
feature-state by road row (color.js, live.js). The changes are the dashboard filters, the base-map popup, the id and the rule that
decides the keys.
2. The id¶
The id of a road piece is its road's row. It is RoadKey (roadstyle-model/src/key.rs): table << 32 | row, and for one build
the table is 0, so the id is the row. All pieces of one road (its casing pieces, halves, seams and fill) have the same id. A
feature-state on the id then applies to the whole road, and the paint expression decides which piece shows it (for example, only the
fill reads rs_c).
- MVT id, not
promoteId. Now the pieces have no MVT id, and the source promotes the property__rs_edge. The contract writes the row as the MVT feature id and removes__rs_edgefrom the tile. The MVT spec says that an id "SHOULD" be unique in a layer, not "MUST". MapLibre accepts the same id on more than one feature: that is howpromoteIdworks now. The page readsf.id, notf.properties.__rs_edge(select.js,core.js,arrows.js;level_editor.jsalready readsf.idfirst). - Base-map features. The id of a feature of
ov0toov14is its row in its feature table (as now). The id spaces of the tile layers are different: a page never mixes them. - Slots (names and arrows). The id of a slot is the row of its road, the same as its pieces.
- The limit 2^53. JavaScript holds an integer exactly only below 2^53. A row is a
u32. ARoadKeystays below 2^53 while the table is below 2^21 (RoadKey::TABLES, a test checks it). duckOSM'sedge_idis a BIGINT hash and is often above 2^53. Thus anedge_idis never a feature id and never a JavaScriptNumber. The data layer gives it as aBigInt64Array. The JSON road table already writes a number above 2^53 as text (JsSafe,roadstyle-model/src/table.rs), but then one column has two types. The new API gives one type. - The id map. The data layer keeps row →
edge_id(the road table'sedge_id_col) andedge_id→ row (a hash map, made one time).rowsOf(edgeIds)givesu32::MAXfor an unknownedge_id. - Relevel. The level editor's
relevelchanges the levels of some roads, not the set of roads. The rows stay the same, so the feature-state and the page's lists stay correct.Tiles::replace_linesreplaces the pieces of the changed roads, and the new pieces have the same row as their id. - A new road set. A new build, a new area or a new mode makes a new road table, and the rows change. This is a new
generation: the tile prefix changes (as
lv://<gen>/now), and the page clears its feature-state. The page keeps a selection or a live table byedge_id, and gets the new rows withrowsOf. A row from one generation is never used in the next. - Items. An item on a road (an overlay attached with
edge_col) is a piece of the road layer, so that the sort key puts it between the fills. Its id is its road's row, so a filter or a glow of the road applies to it. To pick an item as itself, the page needs the item's own number. See decision D3.
The size of the id¶
The MVT feature id is field 1 of a feature: one byte for the field, then the row as a varint. A varint is 1 byte below 128, 2 bytes
below 16,384 and 3 bytes below 2,097,152. The id is not in the tags and not in the layer's value table. The source has no
promoteId: MapLibre uses the MVT id when no promoteId is set. The pieces of one road have the same row, so a road adds no values
to the tile.
Measured on the 30 Burnaby tiles of the tilted view (the simple layer: 87,362 pieces of 20,935 roads, 4.17 MB). A tag is two
varints (the key's index and the value's index). "Distinct values" is the sum over the tiles. Script:
.claude/tile-lag/idcost.py of the workspace (python3 idcost.py tiles/roads).
| Item | Pieces with it | Tags | Value table | Total | Distinct values |
|---|---|---|---|---|---|
__rs_edge now (the row, as a tag) |
87,362 | 258 KB | 146 KB | 404 KB | 25,088 |
__rs_edge2 now (the twin's row) |
43,924 | 131 KB | 67 KB | 198 KB | 11,147 |
__rs_s now (rank × 10^8 + row) |
87,362 | 259 KB | 374 KB | 632 KB | 48,806 |
| MVT id = row, rows as now (max 151,752) | 87,362 | 327 KB (id field) | 0 | 327 KB | 0 |
| MVT id = row, best order for this view | 87,362 | 264 KB (id field) | 0 | 264 KB | 0 |
__rs_r (the small rank, about 100 values) |
87,362 | about 175 KB | under 1 KB | about 175 KB | about 100 |
With the rows as now, 75 % of the pieces have a 3-byte row (row 16,384 or more), 25 % a 2-byte row, and 321 pieces a 1-byte row.
The expected saving (Burnaby, the tilted view, 5.9 MB):
- Phase 2 (the id):
__rs_edgeand__rs_sgo out (−1,036 KB), and the MVT id and__rs_rcome in (+502 KB). The net is −0.53 MB and −74k distinct values. - Phase 4 removes
__rs_edge2(−198 KB, −11k distinct values). - Together: −0.73 MB (−12 %) and −85k of the about 90k distinct values that the id keys give now.
The order of the rows. Smaller numbers for the roads that a view shows most can make the id smaller. The best case is an order made for this one view: the roads of the low zooms first, then by the number of their pieces. It gives 264 KB, not 327 KB: −63 KB, 1 % of the tiles. A real order is for the whole area (151k roads), so fewer roads get a 2-byte row, and the gain is less than this. A spatial order (Hilbert) does not make the numbers smaller: a varint holds the number, not its difference from the last one. A new order also makes a permutation between the road table's input order and the rows. This costs more than 1 % gives, so the rows stay in the road table's order (decision D7).
3. The keys come from the style¶
The set of keys of a tile layer at zoom z is calculated from the style. It is not a list that a person keeps.
- The rule. For each style layer on the tile layer, with z in its
[minzoom, maxzoom), collect each["get", k]and["has", k]in its filter, paint and layout (props::expression_readsdoes this now). A key goes into the tile of zoom z only if that set has it. A key that only a zoom clause makes useless (for example__rs_lapfrom zoom 17) can go out with the same span rule as option A (ZoomRules::span). - What goes out. Now the tiles keep every
__rs_*key, the page's keys (reads.json,pieces),name,kind, the popup's fields and the caller'skeep(props::drawn,TileOptions::rule). The contract keeps only the style's set.keepand the popup fields go to the data layer.reads.json'spieceslist becomes empty (or only the items' keys, D3). - Small values. In an MVT, each key on a feature costs two varints, whatever the type of the value. A value costs its bytes one time for each tile layer. So the number of keys on a feature and the number of different values are what count. Rules:
- a value that has a small fixed set (class, piece kind, flags) is a small integer, and the style reads a table with
["at", ["get", k], ["literal", [...]]]ormatch.__rs_cialready does this. The class minzoom in the filter readshighway, a text. It can read__rs_ciand thenhighwaygoes out of the tile; - a flag that is false is not written (MapLibre reads a missing key as null);
- a value of the look's data (a ramp colour of each road) stays as it is, but only on the piece that draws it.
- The sort key.
__rs_sisrank × 10^8 + row(simple.rs,sort_key). The row is now the id, so the tile gets only the smallrank(__rs_r, about a hundred values) and the style calculates["+", ["*", ["get", "__rs_r"], 1e8], ["id"]]. The order is the same. The raise of the top roads reads["id"], not__rs_edge. A multi-tableRoadKeyis above 10^8, so this form is only for one table. A key of more tables needs its own sort rule later.
4. The data layer API¶
The worker owns the data. The page asks by id. The calls are on the object that makes the tiles (LiveMap, Tiler), so that the
rows and the tiles are always of the same generation. The answers are typed arrays or Arrow IPC bytes, not JSON of the whole
table.
roadstyle-wasm (in the worker; the page calls it through postMessage):
map.generation() // u32, changes when the rows change
map.rows() // the number of roads
map.edgeIds() // BigInt64Array, index = row
map.rowsOf(edgeIds /* BigInt64Array */) // Uint32Array, 0xFFFFFFFF = not found
map.fields(rows /* Uint32Array */, names /* string[] */) // Arrow IPC: one row for each id (popup, side panel)
map.columns(names) // Arrow IPC: whole columns (the dashboard's counts and legends)
map.select(where /* JSON text */) // Uint32Array: the rows that show
map.roadLines(rows) // as now
map.features(layer, rows, names) // Arrow IPC: a base-map layer's fields (ov4's popup)
where is the dashboard's state, for example
{"classes_hidden": ["service"], "access_hidden": ["private"], "bridges": true, "tunnels": false, "ids": null, "modes": ["walking"]}.
select adds the twin row of a two-way pair when either edge shows (now done with __rs_edge2 in the filter). The answer is one
list, so the page does not need __rs_cls, lvl, __rs_access or __rs_edge2 on a piece.
Python (roadstyle binding, the same Rust functions; numpy for the arrays, pyarrow for the tables):
t = roadstyle.Tiler(roads, look)
t.edge_ids() # numpy int64, index = row
t.rows_of(edge_ids) # numpy uint32
t.fields(rows, ["name", "highway"]) # pyarrow.Table
t.select({"classes_hidden": ["service"]}) # numpy uint32
t.features("ov4", rows, ["osm_id", "name"]) # pyarrow.Table
How the page uses it:
- Filters. The page sends
whereto the worker and gets the rows that show. It sets one clause on each road layer:["match", ["id"], [ ...rows ], true, false], or the opposite form with the hidden rows when they are fewer.matchfinds a label with one lookup.["in", ["id"], ["literal", rows]]searches the list, which is slow for 26k features. A filter change makes MapLibre lay out the loaded tiles again. That is also true now (setFilter), and it occurs only when a person changes a switch. - Live colours.
rs.setDatajoins byrowsOf(edgeIds), not by a scan of all the rows in JavaScript, and setsrs_candrs_wby id. The style does not change. - Popup and panel.
fields([row], names)for the road and its twin.RoadTablestays the page's one port; behind it the table can be the full JSON (as now) or a cache offieldsanswers.
5. The checks in CI¶
Two tests. Both use the views that CI already has (Monaco and Tartu; D4 for Burnaby).
The contract test (Rust, roadstyle-tiles, tests/tile_contract.rs). For each tile of the fixed views, decode the MVT and check:
- each key of each tile layer is read by a style layer on that tile layer at the tile's zoom (section 3). The test names the key, the layer and the zoom when it fails;
- each feature is drawn by a style layer at a zoom of the tile (z, z + 0.5 and z + 0.999), with the layer's filter. This needs an
evaluator with all of MapLibre's operators, so this part runs in Node with
@maplibre/maplibre-gl-style-spec(as the analysis script did), next tobench_live_views.mjs; - each feature of the road layer has an MVT id that is a row of the road table.
The JavaScript side: tests/test_contract_names.py already fails when a dashboard module reads a __rs_* property that
reads.json does not declare. With pieces empty, it fails when a module reads a tile property of a piece.
The size budget (done: see "The tile size standard"; rust/roadstyle-rs/tests/data/tile_budget.json). For each fixed view, for each tile layer: the
features, the bytes, the keys and the different values of each key. The test fails when a number is more than 5 % above the
budget. A change that makes the tiles smaller writes the new budget in the same commit. The budget also has the JSON size of each
data-driven paint and layout property of the road layer (section 6), so a large expression fails CI too.
6. The expressions¶
The expr/ test (.claude/tile-lag/expr/README.md) changes one item of the live style and measures the time from the jump to an idle
map: Burnaby, the tilted view, the tiles of cfa80c2, headless Chromium with software GL, the median of 5 runs, each on a new page,
with a 200 ms start wait in each time.
| Variant | ms to idle | Against now |
|---|---|---|
| now | 1416 | |
all 5 paint and 3 layout properties of simple constant |
768 | −648 |
only line-width as an interpolate on the zoom only (no feature data) |
1032 | −384 |
only line-color constant |
1334 | −82 |
only line-opacity constant |
1352 | −64 |
only line-offset constant |
1364 | −52 |
only line-dasharray, line-sort-key, line-cap or line-blur changed |
1400 to 1452 | in the noise |
the simple layer removed (the other layers stay) |
568 | −848 |
the simple layer two times |
2190 | +774 |
half of the other layers on the rs-roads source removed (24 of 49) |
1232 | −184 |
all other layers on the rs-roads source removed (49) |
1083 | −333 |
The size of the expressions (characters of JSON): line-width 29,032, line-offset 3,184, line-color 1,895, the filter 859, each
other property below 300. In the worker's CPU profile of "now" (1,576 ms of samples), evaluate, MapLibre's evaluation of style
expressions, is the top function: 311 ms, 20 %.
The style has 50 layers on the vector source rs-roads. One of them (rs-roads-simple) reads the road layer simple. The others
read the overlays and the base-map layers (ov0 to ov14). 12 of the 50 are visible.
What this shows:
- The expressions are the main cause of the lag. The road layer costs about 0.85 s. About 0.65 s of it is the calculation of its paint and layout expressions, and about 0.2 s is the drawing of its features.
line-widthalone is 0.38 s. It is aninterpolateover 9 zoom stops (12 to 20). Each stop is acaseon the piece kind, pair, bridge and twin, with tables of the 29 classes (["at", ["get", "__rs_ci"], ["literal", [...]]], about 3 KB for each stop). MapLibre calculates it for each feature at two zooms.- The sort key has no cost in time. Its cost is its 49k different values (section 2).
- Each style layer on the same features costs again. One road layer is correct. A second layer for the casing is not a gain. The other 49 layers cost 0.33 s together. Their expressions get the same budget as the road layer's.
How to keep the expressions small:
- One small width key for each piece, from the engine. The width reads 5 keys (
__rs_k,__rs_ci,__rs_pair,__rs_bridge,__rs_twoway). The engine already knows each piece's width row. It writes one small integer__rs_wk, the index of that row, with fewer than 200 different values (1 byte in each tag). The style then has one width table for each zoom stop:["interpolate", ["linear"], ["zoom"], 12, ["at", ["get", "__rs_wk"], ["literal", [...]]], 13, ...]. It is the same map: the same widths at the same zooms, with one lookup for each stop, not acasetree. The live width stays a factor:["*", ..., ["coalesce", ["feature-state", "rs_w"], 1]]. - The same index for the offset, where the offset reads the same keys (
__rs_twoway,__rs_pair,__rs_ci). - Fewer stops where the curve lets it. The classes do not grow with one curve (for example, from zoom 12 to 20 one class grows 25 times and another 94 times), so one base width times a shared curve changes the pixels. A class whose widths are on one exponential curve can have 2 stops, not 9.
- A budget for each data-driven property (section 5): 4 KB.
- The same pixels. A change of an expression must not change the picture. The pixel goldens and the Burnaby pictures of
expr/decide this.
The zoom-only width gives the top limit of the gain (−0.38 s). A width that reads one key is still data-driven, so its gain is less.
A probe before the code: in the expr/ harness, make each stop one ["at", ["get", "__rs_ci"], ...] lookup with the saved tiles.
This has the same cost as __rs_wk (the pixels are wrong, the time is correct).
7. The plan¶
Each phase can ship alone. Each phase that changes the tile bytes writes the new tile goldens and the new budget. The small expressions come first: they have the largest gain and need no page change.
| Phase | roadstyle | The page (dashboard, duckosm-rust) | Expected gain (Burnaby, tilted view) | Risks | Tests |
|---|---|---|---|---|---|
0. Undrawn features out (done, 5f79037) |
ZoomRules::span |
none | −15k features, −0.6 MB; time in the noise | the span tests, the Burnaby hashes | |
| 1. Small expressions | the engine writes __rs_wk (and the offset's index); line-width and line-offset as one table lookup for each zoom stop; the same budget for the other 49 layers |
none | up to −0.38 s (width); up to −0.65 s with all of the road layer's expressions; the probe gives the real number first | a change of pixels; one more key on each piece (1-byte tag) | pixel goldens, the expr/ harness, the expression budget |
| 2. The id | MVT id = road row on simple and the slots; no promoteId; __rs_edge out of the tile; sort key from __rs_r and ["id"] |
read f.id (select.js, core.js, arrows.js, editor); no promoteId in addSource |
−74k different values, −0.53 MB (measured, section 2); time small (the sort key test showed no time) | MapLibre with the same id on many features (now true with promoteId, so low); an old page with a new style |
sort-key order test (sources.rs), glow and live colour e2e, editor e2e |
| 3. The data layer | generation, edgeIds, rowsOf, fields, columns, select, features in roadstyle-wasm and Python |
filters from select, rs.setData with rowsOf, ov4 popup from features |
none alone; it lets phase 4 remove keys | a filter change with 150k ids (size of the match); a popup that waits for the worker |
unit tests of select (twins, modes), filter e2e, popup e2e |
| 4. Keys from the style | TileOptions::rule keeps the style's keys of each zoom only; highway replaced by __rs_ci in the filter; the 5 old width keys go out of the tile where no expression reads them; reads.json pieces empty |
no page read of a tile property | −4 keys on 87k pieces, −osm_id/kind/name on 24k buildings: with phase 2, −0.8 to −1.0 MB of 5.9 MB; time about −0.05 to −0.1 s |
a reader that nobody found: the contract test and test_contract_names.py find it |
the contract test, the size budget |
The sum: the control now takes 1.42 s, and the roads are 0.85 s of it. Phase 1 gives the time. Phases 2 to 4 give the bytes and a clean contract, with little time.
A larger change, one casing for the two halves of a two-way road (option B in ANALYSIS.md, about −18k features), is not in this
plan. It changes the picture and the live colour of each direction, so it needs its own note.
Decisions for Kaveh¶
| Decision | Recommendation | |
|---|---|---|
| D1 | The pieces' id: the MVT id (no promoteId), or keep promoteId on __rs_edge |
the MVT id: one key less on each piece, and the page reads f.id |
| D2 | The dashboard filters: an id list in the filter (a layout of the loaded tiles on each change), or a feature-state rs_off in the paint (no layout, but a hidden road is still drawn and a click still finds it) |
the id list with match: a hidden road costs nothing, as with the filters now |
| D3 | An item picked as itself: keep __rs_pick and the item's number as the only declared page keys on items, or give items their own id range |
the declared keys: few features have them, and the road's filter and glow stay one lookup |
| D4 | The views of the CI checks: Monaco and Tartu in CI; Burnaby only as a local bench gate (its data is not in CI) | yes |
| D5 | The width: one index key __rs_wk (the same pixels, one lookup for each stop), or one base width times a shared zoom curve (2 stops, but the pixels change) |
__rs_wk; first the probe in expr/ |
| D6 | The order: small expressions first (phase 1, as in this note), or the id and the data layer first | expressions first: the largest gain, and no page code |
| D7 | The rows: in the road table's order, or numbered again so that the roads of the low zooms get 1- and 2-byte ids | the road table's order: a new order gives 1 % at most (−63 KB in the best case) and adds a permutation |