# Map Layers End-to-end layer pipeline for the MapLibre-based map, from OSM tags through tilemaker to style layers. ## Pipeline Two tilemaker pipelines produce PMTiles, both emitting an OpenMapTiles-compatible schema: | Pipeline | Config | Processor | Zoom | Output | | --------------- | --------------------------------------------- | ------------------- | ---- | --------------------- | | World overview | `data/cli/shared/tilemaker/config.world.json` | `process.world.lua` | 0–9 | single global PMTiles | | Regional detail | `data/cli/shared/tilemaker/config.json` | `process.lua` | 0–14 | per-region PMTiles | Regional pipelines extract full OSM detail. The world pipeline is a deliberately stripped subset — no buildings, housenumbers, POIs, aeroways, or minor roads — because those are distracting at global zoom and only appear once a region is downloaded. MapLibre consumes both through `www/utils/layers.ts`: - The **default export** returns regional style layers for a given source name. - The **named `WORLD_LAYERS` export** returns style layers bound to the `world` source. Loading order (`www/components/m-map.ts`): 1. `#loadWorldTiles()` adds the `world` source + `WORLD_LAYERS` on init. 2. `#addRegionalSource()` inserts each regional source's layers **before the first world label** (`place-continent`) as regions are downloaded. This keeps world place labels above regional water/roads so city/country names aren't hidden by regional fills and lines. 3. Bookmark layers are appended and `#ensureBookmarkLayersOnTop()` keeps them above everything else. Final stack (bottom → top): world base → regional layers → world labels → bookmarks. ## Source-layer coverage Source-layers below are what tilemaker emits — MapLibre style layers reference them via `source-layer`. ### Regional (`config.json`) | Source-layer | Zoom | Notes | | --------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `place` | 0–14 | country, city, town, suburb, quarter, neighbourhood | | `boundary` | 0–14 | admin_level 2–8 | | `water` | 6–14 | lakes, rivers, reservoirs | | `ocean` | 0–14 | loaded from coastline shapefile → `water` | | `waterway` | 8–14 | named rivers (+ `waterway_detail` z12–14) | | `landcover` | 0–14 | grass, wood, sand, ice, farmland | | `landuse` | 4–14 | residential only | | `park` | 11–14 | national_park, nature_reserve | | `transportation` | 4–14 | roads, rail, ferry, pier, aerialway; rail-family route relations additionally add `is_route`/`route_ref`/`route_name`/`route_colour`/`route_network` (one extra feature per route a way belongs to) | | `transportation_name` | 8–14 | road + rail names | | `aeroway` | 11–14 | runways, taxiways | | `aerodrome_label` | 10–14 | | | `building` | 12–14 | | | `housenumber` | 14 | | | `poi` / `poi_detail` | 12–14 | `poi_detail` (rank 5+) written into `poi` | ### World (`config.world.json`) | Source-layer | Zoom | Notes | | ------------ | ---- | ----------------------------------------- | | `place` | 0–9 | continent through village | | `boundary` | 0–9 | | | `water` | 4–9 | inland water | | `ocean` | 0–9 | loaded from coastline shapefile → `water` | | `waterway` | 8–9 | named rivers only | | `landcover` | 0–9 | grass, wood, wetland, sand, rock, ice | | `park` | 7–9 | | Intentionally omitted from world: transportation (all roads/rail/ferry), buildings, housenumbers, POIs, aeroways, landuse, mountain peaks. ## Style layers MapLibre evaluates every layer per frame during pan/zoom. With multiple regional tile sources loaded simultaneously, the total layer count is `(regional layers × regions) + world layers + bookmark layers`. Keeping the per-source layer count low directly improves frame time. | Source | Layers | | --------------------- | ------ | | Regional (per source) | 39 | | World | 10 | | Bookmarks | 5 | With 3 regional tiles loaded: **132 layers total**. ### Regional layers (`layers.ts` default export) 39 layers, rendered bottom to top: | # | Layer ID | Type | Source-layer | Notes | | -- | ------------------------------- | ------ | ------------------- | --------------------------------------------------------------------------------------------------- | | 1 | `landuse-residential` | fill | landuse | z11+, residential / suburb / neighbourhood | | 2 | `landcover` | fill | landcover | **Merged** grass + wood + sand via `match` on class | | 3 | `water` | fill | water | **Merged** normal + intermittent via opacity expression | | 4 | `landcover-ice-shelf` | fill | landcover | Separate — renders above water | | 5 | `landcover-glacier` | fill | landcover | Separate — opacity ramp z0→z8 | | 6 | `landuse` | fill | landcover | Farmland fill | | 7 | `landuse_overlay_national_park` | fill | park | National parks + nature reserves, opacity ramp z11→z13 | | 8 | `waterway-tunnel` | line | waterway | Dashed + gap-width (unique paint) | | 9 | `waterway` | line | waterway | Normal waterways | | 10 | `waterway_intermittent` | line | waterway | Dashed (can't merge — `line-dasharray` not data-driven) | | 11 | `tunnel_railway_transit` | line | transportation | Kept separate — opacity ramp differs from road tunnels | | 12 | `building` | fill | building | z13+ fade-in | | 13 | `housenumber` | symbol | housenumber | z17+ | | 14 | `road_area_pier` | fill | transportation | Polygon piers | | 15 | `road_pier` | line | transportation | Line piers | | 16 | `road_bridge_area` | fill | transportation | Bridge polygons | | 17 | `road_path` | line | transportation | Kept separate — dashed, square cap | | 18 | `tunnel_road` | line | transportation | **Merged** minor + major tunnels | | 19 | `aeroway-area` | fill | aeroway | Runway / taxiway polygons | | 20 | `aeroway-taxiway` | line | aeroway | Kept separate — different minzoom / width from runway | | 21 | `aeroway-runway` | line | aeroway | z4+ | | 22 | `road` | line | transportation | **Merged** minor, service, secondary, tertiary, trunk, primary, motorway — class-based `line-width` | | 23 | `railway` | line | transportation | **Merged** rail + transit | | 24 | `transit-route-case` | line | transportation | White casing for the route overlay below | | 25 | `transit-route` | line | transportation | Thin coloured line, one treatment for every rail-family mode (metro and long-distance alike) | | 26 | `transit-route-label` | symbol | transportation | z12+, line placement, route `ref` | | 27 | `transit-station` | circle | poi | z13+, `class=railway` (station/halt/tram_stop) | | 28 | `transit-station-label` | symbol | poi | z14+, station name | | 29 | `waterway-bridge-case` | line | waterway | Different source-layer from road bridges | | 30 | `waterway-bridge` | line | waterway | | | 31 | `bridge_case` | line | transportation | **Merged** minor + major bridge casing | | 32 | `bridge` | line | transportation | **Merged** minor + major bridge fill | | 33 | `admin_sub` | line | boundary | State / province boundaries | | 34 | `admin_country` | line | boundary | z5+ only — world tiles cover z0–5 | | 35 | `poi_label` | symbol | poi | z15+, rank 1 only | | 36 | `airport-label` | symbol | aerodrome_label | z11+, IATA airports | | 37 | `road_major_label` | symbol | transportation_name | z14+, line placement | | 38 | `place_label` | symbol | place | **Merged** city + town + suburb / neighbourhood / quarter — single collision pass | | 39 | `country_label` | symbol | place | Country names, z7–11 (world covers z0–6) | ### World layers (`WORLD_LAYERS` export) 10 layers, rendered bottom to top: | # | Layer ID | Type | Source-layer | Notes | | -- | ------------------ | ------ | ------------ | ------------------------------------------------------------------------------ | | 1 | `landcover` | fill | landcover | **Merged** grass, wood, wetland, sand, rock, ice — class-based color + opacity | | 2 | `water` | fill | water | Oceans + inland water — above landcover to mask coast-overlapping polygons | | 3 | `park` | fill | park | National parks, nature reserves | | 4 | `waterway` | line | waterway | z6+, named rivers | | 5 | `boundary-state` | line | boundary | z3+, admin_level 3–6 | | 6 | `boundary-country` | line | boundary | admin_level 2 | | 7 | `place-continent` | symbol | place | z0–3, uppercase | | 8 | `place-country` | symbol | place | maxzoom 7, Medium font for sovereign states | | 9 | `place-state` | symbol | place | z4–8, uppercase | | 10 | `place-settlement` | symbol | place | **Merged** city + town + village, z6–10 (regional takes over at z11) | ### Bookmark layers User bookmarks rendered on top of all map layers. `#ensureBookmarkLayersOnTop()` re-moves them to the top whenever a new tile source is added. | Layer ID | Type | Purpose | | ------------------------------- | ------ | --------------------------------------- | | `bookmarks-clusters` | circle | Clustered bookmark pins (5+ points) | | `bookmarks-cluster-count` | circle | Invisible hit area for clusters | | `bookmarks-cluster-count-label` | symbol | Cluster count text | | `bookmarks` | circle | Individual bookmark pins | | `bookmarks-hit` | circle | Invisible larger hit area for bookmarks | ## Design decisions ### World and regional dovetail at z5 Country boundaries: regional `admin_country` sets `minzoom: 5` so it only kicks in where world tiles stop producing meaningful boundary detail. Without this, country boundaries would render twice from z5 onward. Other regional layers (water, landcover, …) technically cover z0–14 but are only attached to the map after a region is downloaded, and at low zooms the regional source emits very little data due to `SetMinZoomByArea` throttling in `process.lua`. ### Label handoff between world and regional Country and settlement labels hand off cleanly instead of overlapping: | Label | World | Regional | | ---------- | ------------------------ | --------------------- | | country | `place-country` z0–6 | `country_label` z7–11 | | settlement | `place-settlement` z6–10 | `place_label` z11–15 | The regional layer's `minzoom` is set to the same value as the world layer's `maxzoom` (exclusive), so there is no zoom at which both render. The tradeoff: if no region is downloaded, country labels disappear at z7+ and settlement labels disappear at z11+ — acceptable because at those zooms the map has little else to show without regional data. ### Why some layers are NOT merged **`line-dasharray` is not data-driven** in MapLibre GL — it's a constant per layer. This prevents merging layers that need different dash patterns (e.g. `waterway` vs `waterway_intermittent`, `road_path` vs solid roads). **Render order matters for bridge casing.** The `bridge_case` layer must render below `bridge` to create the outline effect. They can't be a single layer. **Source-layer boundaries.** Layers using different source-layers (e.g. `waterway` vs `transportation`) cannot be merged even if they look similar. **Opacity ramps diverge too much.** Ice-shelf (constant 0.8), glacier (z0→z8 ramp), and national park (z5→z9 ramp) have fundamentally different opacity behavior. Merging into one expression with multiple zoom stops adds maintenance cost for minimal gain (3 → 1 layer, but these are simple fills). ### Merge patterns used **`match` on feature property** — most common. Used for per-class color, line-width, text-size, opacity. Example: landcover uses `['match', ['get', 'class'], 'sand', 'rgba(...)', 'hsl(...)']`. **`interpolate` + `match` composite** — for zoom-dependent values that vary by class. The outer `interpolate` drives the zoom, and each stop uses a `match` to return the class-specific value. Example: road `line-width` varies by both zoom and road class. **`case` on feature property** — for boolean conditions. Example: water opacity uses `['case', ['==', ['get', 'intermittent'], 1], 0.7, 1]`. **`text-size: 0` to hide features** — for per-class minzoom within a merged symbol layer. MapLibre skips rendering features with text-size 0, so this effectively implements per-class visibility without separate layers (used in `place_label`). ### CSS z-index Container and controls are stacked so map controls sit above the canvas without breaking DOM order: - `m-map` container: `z-index: 1` - Map canvas: `z-index: 1` - MapLibre controls: `z-index: 10` - Download button: `z-index: 10` ### Transit route identity Rail-family route relations (`type=route`, `route` in `train`/`subway`/ `light_rail`/`tram`/`funicular`/`monorail` — bus is out of scope) are scanned in `relation_scan_function` and pulled onto their member ways via tilemaker's `NextRelation()`/`FindInRelation()`. Each route a way belongs to becomes an **additional** `transportation` feature (`is_route=1`, plus `route_ref`/ `route_name`/`route_colour`/`route_network` where OSM supplies them) — the plain track write is untouched, so hiding the `transit-route-*` layers client-side reverts to a single grey line. A shared trunk (e.g. several Cercanías lines running the same track into a terminus) gets one duplicate feature per route rather than picking one, so none of the lines using that track lose their branding. One treatment applies to every rail-family mode alike — metro and long-distance both render thin, in the route's real colour, with a white casing underneath (`transit-route-case` under `transit-route`, the same case-then-line technique as `bridge_case`/`bridge`). Keeps overlapping lines on shared trunk track legible instead of overwhelming, without muting any mode to plain grey. **Ordering pitfall**: `NextRelation()`/`FindInRelation()` only see a way's relation membership when called early in `way_function` — called later (e.g. inline in the Railways block, after the function's other `Find()` calls and branches), they silently return nothing, with no error. Route relations must be captured into a local table (`railRoutes`) immediately after `local railway = Find("railway")`, then consumed later where the feature is actually written. **Measured cost** (Madrid — Metro + Cercanías, a dense rail network — rebuilt under identical conditions): baseline 32,387,507 bytes → 32,871,369 bytes with route identity plus bringing `subway`/`tram`/`monorail`/`funicular` forward from minzoom 14 to 11 (matching `light_rail`). **+483,862 bytes, +1.49%.** Rail geometry is a small fraction of a city's total mapped data even duplicated per overlapping route, but it is not free — plan for roughly this order of magnitude (more in transit-dense megacities with many overlapping lines, less almost everywhere else) when re-budgeting archive size.