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_LAYERSexport returns style layers bound to theworldsource.
Loading order (www/components/m-map.ts):
#loadWorldTiles()adds theworldsource +WORLD_LAYERSon init.#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.- 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-mapcontainer: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.