Offline-capable geomap for storing location bookmarks
maps docs layers.md
22 kB · 283 lines
Markdown
at main

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.