Offline-capable geomap, meant for storing location bookmarks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: adjust layers

+585 -222
+2 -2
www/components/m-map.ts
··· 9 9 } from '../utils/fs.ts' 10 10 import { getMapNav, setMapNav } from '../utils/nav.ts' 11 11 import layers from '../utils/layers.ts' 12 - import worldLayers from '../utils/world_layers.ts' 12 + import { WORLD_LAYERS } from '../utils/layers.ts' 13 13 import app from '../models/app.ts' 14 14 import { bookmarkDisplayName } from '../models/schema.ts' 15 15 import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' ··· 649 649 url: `pmtiles://${worldFilename}`, 650 650 attribution: '© OpenStreetMap contributors', 651 651 }) 652 - worldLayers.forEach((layer) => 652 + WORLD_LAYERS.forEach((layer) => 653 653 this.#map!.addLayer(layer as maplibregl.AddLayerObject) 654 654 ) 655 655 }
+109
www/utils/LAYERS.md
··· 1 + # Map Layer Architecture 2 + 3 + MapLibre GL evaluates every layer per frame during pan/zoom. With multiple 4 + regional tile sources loaded simultaneously, the total layer count is 5 + `(regional layers x regions) + world layers + bookmark layers`. Keeping the 6 + per-source layer count low directly improves frame time. 7 + 8 + ## Layer counts 9 + 10 + | Source | Before | After | Saved | 11 + |--------|--------|-------|-------| 12 + | Regional (per tile) | 47 | 34 | 13 | 13 + | World | 17 | 10 | 7 | 14 + | Bookmarks | 5 | 5 | — | 15 + 16 + With 3 regional tiles loaded: **156 → 117 layers** (25% reduction). 17 + 18 + ## Regional layers (`layers.ts`) 19 + 20 + 34 layers, rendered bottom to top: 21 + 22 + | # | Layer ID | Type | Source-layer | Notes | 23 + |---|----------|------|-------------|-------| 24 + | 1 | `landuse-residential` | fill | landuse | z11+, residential/suburb/neighbourhood | 25 + | 2 | `landcover` | fill | landcover | **Merged** grass + wood + sand via `match` on class | 26 + | 3 | `water` | fill | water | **Merged** normal + intermittent via opacity expression | 27 + | 4 | `landcover-ice-shelf` | fill | landcover | Separate — renders above water | 28 + | 5 | `landcover-glacier` | fill | landcover | Separate — opacity ramp z0→z8 | 29 + | 6 | `landuse` | fill | landuse | Agriculture | 30 + | 7 | `landuse_overlay_national_park` | fill | landcover | Opacity ramp z5→z9 | 31 + | 8 | `waterway-tunnel` | line | waterway | Dashed + gap-width (unique paint) | 32 + | 9 | `waterway` | line | waterway | Normal waterways | 33 + | 10 | `waterway_intermittent` | line | waterway | Dashed (can't merge — `line-dasharray` not data-driven) | 34 + | 11 | `tunnel_railway_transit` | line | transportation | Kept separate — opacity ramp differs from road tunnels | 35 + | 12 | `building` | fill | building | z13+ fade-in | 36 + | 13 | `housenumber` | symbol | housenumber | z17+ | 37 + | 14 | `road_area_pier` | fill | transportation | Polygon piers | 38 + | 15 | `road_pier` | line | transportation | Line piers | 39 + | 16 | `road_bridge_area` | fill | transportation | Bridge polygons | 40 + | 17 | `road_path` | line | transportation | Kept separate — dashed, square cap | 41 + | 18 | `tunnel_road` | line | transportation | **Merged** minor + major tunnels | 42 + | 19 | `aeroway-area` | fill | aeroway | Runway/taxiway polygons | 43 + | 20 | `aeroway-taxiway` | line | aeroway | Kept separate — different minzoom/width from runway | 44 + | 21 | `aeroway-runway` | line | aeroway | z4+ | 45 + | 22 | `road` | line | transportation | **Merged** minor, service, secondary, tertiary, trunk, primary, motorway — class-based `line-width` | 46 + | 23 | `railway` | line | transportation | **Merged** rail + transit | 47 + | 24 | `waterway-bridge-case` | line | waterway | Different source-layer from road bridges | 48 + | 25 | `waterway-bridge` | line | waterway | | 49 + | 26 | `bridge_case` | line | transportation | **Merged** minor + major bridge casing | 50 + | 27 | `bridge` | line | transportation | **Merged** minor + major bridge fill | 51 + | 28 | `admin_sub` | line | boundary | State/province boundaries | 52 + | 29 | `admin_country` | line | boundary | z5+ only — world tiles cover z0–5 | 53 + | 30 | `poi_label` | symbol | poi | z15+, rank 1 only | 54 + | 31 | `airport-label` | symbol | aerodrome_label | z11+, IATA airports | 55 + | 32 | `road_major_label` | symbol | transportation_name | z14+, line placement | 56 + | 33 | `place_label` | symbol | place | **Merged** city + town + suburb/neighbourhood/quarter — single collision pass, class-based text-size/font/color | 57 + | 34 | `country_label` | symbol | place | Country names z0–12 | 58 + 59 + ## World layers (`world_layers.ts`) 60 + 61 + 10 layers: 62 + 63 + | # | Layer ID | Type | Source-layer | Notes | 64 + |---|----------|------|-------------|-------| 65 + | 1 | `water` | fill | water | Oceans + inland water | 66 + | 2 | `landcover` | fill | landcover | **Merged** grass, wood, wetland, sand, rock, ice — class-based color + opacity | 67 + | 3 | `park` | fill | park | National parks, nature reserves | 68 + | 4 | `waterway` | line | waterway | z6+, named rivers | 69 + | 5 | `boundary-state` | line | boundary | z3+, admin levels 3–6 | 70 + | 6 | `boundary-country` | line | boundary | Admin level 2 | 71 + | 7 | `place-continent` | symbol | place | z0–3, uppercase | 72 + | 8 | `place-country` | symbol | place | z0–7, Medium font for sovereign states | 73 + | 9 | `place-state` | symbol | place | z4–8, uppercase | 74 + | 10 | `place-settlement` | symbol | place | **Merged** city + town + village — class-based text-size | 75 + 76 + ## Why some layers are NOT merged 77 + 78 + **`line-dasharray` is not data-driven** in MapLibre GL — it's a constant per layer. 79 + This prevents merging layers that need different dash patterns (e.g., waterway 80 + vs waterway_intermittent, road_path vs solid roads). 81 + 82 + **Render order matters for bridge casing.** The `bridge_case` layer must render 83 + below `bridge` to create the outline effect. They can't be a single layer. 84 + 85 + **Source-layer boundaries.** Layers using different source-layers (e.g., 86 + `waterway` vs `transportation`) cannot be merged even if they look similar. 87 + 88 + **Opacity ramps diverge too much.** Ice-shelf (constant 0.8), glacier (z0→z8 89 + ramp), and national park (z5→z9 ramp) have fundamentally different opacity 90 + behavior. Merging them into one expression with multiple zoom stops adds 91 + maintenance cost for minimal gain (3 → 1 layer, but these are simple fills). 92 + 93 + ## Merge patterns used 94 + 95 + **`match` on feature property** — most common. Used for per-class color, 96 + line-width, text-size, opacity. Example: landcover uses 97 + `['match', ['get', 'class'], 'sand', 'rgba(...)', 'hsl(...)']`. 98 + 99 + **`interpolate` + `match` composite** — for zoom-dependent values that vary by 100 + class. The outer `interpolate` drives the zoom, and each stop uses a `match` 101 + to return the class-specific value. Example: road line-width varies by both 102 + zoom and road class. 103 + 104 + **`case` on feature property** — for boolean conditions. Example: water 105 + opacity uses `['case', ['==', ['get', 'intermittent'], 1], 0.7, 1]`. 106 + 107 + **text-size = 0 to hide features** — for per-class minzoom within a merged 108 + symbol layer. MapLibre skips rendering features with text-size 0, so this 109 + effectively implements per-class visibility without separate layers.
+474 -220
www/utils/layers.ts
··· 3 3 * @reference https://github.com/protomaps/basemaps/issues/15#issuecomment-1436048491 4 4 * @reference https://github.com/openmaptiles/maptiler-basic-gl-style 5 5 */ 6 + const ALL = 'all' 7 + const LINE = ['==', '$type', 'LineString'] 8 + const POLYGON = ['==', '$type', 'Polygon'] 9 + const MINOR_ROAD = ['minor_road', 'primary', 'secondary', 'tertiary', 'trunk'] 10 + 11 + const ELEVEN_SIXTEEN = { 'base': 1, 'stops': [[8, 0], [16, 1]] } 12 + 13 + const BLUE = 'hsl(205, 56%, 73%)' 14 + const BLUE_GRAY = 'hsl(47, 26%, 88%)' 15 + const WATER_LINE = { 16 + 'line-color': BLUE, 17 + 'line-opacity': 1, 18 + 'line-width': { 'base': 1.4, 'stops': [[8, 1], [20, 8]] }, 19 + } 20 + 6 21 export default function (sourceName: string) { 7 22 return [ 8 23 { ··· 12 27 'source-layer': 'landuse', 13 28 'minzoom': 11, 14 29 'filter': [ 15 - 'all', 16 - ['==', '$type', 'Polygon'], 30 + ALL, 31 + POLYGON, 17 32 ['in', 'class', 'residential', 'suburb', 'neighbourhood'], 18 33 ], 19 34 'layout': { 'visibility': 'visible' }, ··· 53 68 'type': 'fill', 54 69 'source': sourceName, 55 70 'source-layer': 'water', 56 - 'filter': [ 57 - 'all', 58 - ['==', '$type', 'Polygon'], 59 - ['!=', 'intermittent', 1], 60 - ['!=', 'brunnel', 'tunnel'], 61 - ], 62 - 'layout': { 'visibility': 'visible' }, 63 - 'paint': { 'fill-color': 'hsl(205, 56%, 73%)' }, 64 - }, 65 - { 66 - 'id': `${sourceName}-water_intermittent`, 67 - 'type': 'fill', 68 - 'source': sourceName, 69 - 'source-layer': 'water', 70 - 'filter': ['all', ['==', '$type', 'Polygon'], ['==', 'intermittent', 1]], 71 - 'layout': { 'visibility': 'visible' }, 72 - 'paint': { 'fill-color': 'hsl(205, 56%, 73%)', 'fill-opacity': 0.7 }, 71 + 'filter': [ALL, POLYGON, ['!=', 'brunnel', 'tunnel']], 72 + 'paint': { 73 + 'fill-color': BLUE, 74 + 'fill-opacity': ['case', ['==', ['get', 'intermittent'], 1], 0.7, 1], 75 + }, 73 76 }, 74 77 { 75 78 'id': `${sourceName}-landcover-ice-shelf`, ··· 78 81 'source-layer': 'landcover', 79 82 'filter': ['==', 'subclass', 'ice_shelf'], 80 83 'layout': { 'visibility': 'visible' }, 81 - 'paint': { 'fill-color': 'hsl(47, 26%, 88%)', 'fill-opacity': 0.8 }, 84 + 'paint': { 'fill-color': BLUE_GRAY, 'fill-opacity': 0.8 }, 82 85 }, 83 86 { 84 87 'id': `${sourceName}-landcover-glacier`, ··· 117 120 'type': 'line', 118 121 'source': sourceName, 119 122 'source-layer': 'waterway', 120 - 'filter': [ 121 - 'all', 122 - ['==', '$type', 'LineString'], 123 - ['==', 'brunnel', 'tunnel'], 124 - ], 123 + 'filter': [ALL, LINE, ['==', 'brunnel', 'tunnel']], 125 124 'layout': { 'visibility': 'visible' }, 126 125 'paint': { 127 - 'line-color': 'hsl(205, 56%, 73%)', 126 + 'line-color': BLUE, 128 127 'line-dasharray': [3, 3], 129 128 'line-gap-width': { 'stops': [[12, 0], [20, 6]] }, 130 129 'line-opacity': 1, ··· 137 136 'source': sourceName, 138 137 'source-layer': 'waterway', 139 138 'filter': [ 140 - 'all', 141 - ['==', '$type', 'LineString'], 139 + ALL, 140 + LINE, 142 141 ['!in', 'brunnel', 'tunnel', 'bridge'], 143 142 ['!=', 'intermittent', 1], 144 143 ], 145 144 'layout': { 'visibility': 'visible' }, 146 - 'paint': { 147 - 'line-color': 'hsl(205, 56%, 73%)', 148 - 'line-opacity': 1, 149 - 'line-width': { 'base': 1.4, 'stops': [[8, 1], [20, 8]] }, 150 - }, 145 + 'paint': WATER_LINE, 151 146 }, 152 147 { 153 148 'id': `${sourceName}-waterway_intermittent`, ··· 155 150 'source': sourceName, 156 151 'source-layer': 'waterway', 157 152 'filter': [ 158 - 'all', 159 - ['==', '$type', 'LineString'], 153 + ALL, 154 + LINE, 160 155 ['!in', 'brunnel', 'tunnel', 'bridge'], 161 156 ['==', 'intermittent', 1], 162 157 ], 163 158 'layout': { 'visibility': 'visible' }, 164 - 'paint': { 165 - 'line-color': 'hsl(205, 56%, 73%)', 166 - 'line-dasharray': [2, 1], 167 - 'line-opacity': 1, 168 - 'line-width': { 'base': 1.4, 'stops': [[8, 1], [20, 8]] }, 169 - }, 159 + 'paint': { ...WATER_LINE, 'line-dasharray': [2, 1] }, 170 160 }, 171 161 { 172 162 'id': `${sourceName}-tunnel_railway_transit`, ··· 175 165 'source-layer': 'transportation', 176 166 'minzoom': 0, 177 167 'filter': [ 178 - 'all', 179 - ['==', '$type', 'LineString'], 168 + ALL, 169 + LINE, 180 170 ['==', 'brunnel', 'tunnel'], 181 171 ['==', 'class', 'transit'], 182 172 ], ··· 184 174 'paint': { 185 175 'line-color': 'hsl(34, 12%, 66%)', 186 176 'line-dasharray': [3, 3], 187 - 'line-opacity': { 'base': 1, 'stops': [[11, 0], [16, 1]] }, 177 + 'line-opacity': ELEVEN_SIXTEEN, 188 178 }, 189 179 }, 190 180 { ··· 195 185 'paint': { 196 186 'fill-antialias': true, 197 187 'fill-color': 'rgba(222, 211, 190, 1)', 198 - 'fill-opacity': { 'base': 1, 'stops': [[13, 0], [15, 1]] }, 188 + 'fill-opacity': ELEVEN_SIXTEEN, 199 189 'fill-outline-color': { 200 190 'stops': [ 201 - [15, 'rgba(212, 177, 146, 0)'], 191 + [13, 'rgba(212, 177, 146, 0)'], 202 192 [16, 'rgba(212, 177, 146, 0.5)'], 203 193 ], 204 194 }, ··· 225 215 'source': sourceName, 226 216 'source-layer': 'transportation', 227 217 'minzoom': 13, 228 - 'filter': ['all', ['==', '$type', 'Polygon'], ['==', 'class', 'pier']], 218 + 'filter': [ALL, POLYGON, ['==', 'class', 'pier']], 229 219 'layout': { 'visibility': 'visible' }, 230 - 'paint': { 'fill-antialias': true, 'fill-color': 'hsl(47, 26%, 88%)' }, 220 + 'paint': { 'fill-antialias': true, 'fill-color': BLUE_GRAY }, 231 221 }, 232 222 { 233 223 'id': `${sourceName}-road_pier`, ··· 236 226 'source': sourceName, 237 227 'source-layer': 'transportation', 238 228 'minzoom': 13, 239 - 'filter': ['all', ['==', '$type', 'LineString'], ['in', 'class', 'pier']], 229 + 'filter': [ALL, LINE, ['in', 'class', 'pier']], 240 230 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 241 231 'paint': { 242 - 'line-color': 'hsl(47, 26%, 88%)', 232 + 'line-color': BLUE_GRAY, 243 233 'line-width': { 'base': 1.2, 'stops': [[15, 1], [17, 4]] }, 244 234 }, 245 235 }, ··· 250 240 'source-layer': 'transportation', 251 241 'minzoom': 12, 252 242 'filter': [ 253 - 'all', 254 - ['==', '$type', 'Polygon'], 243 + ALL, 244 + POLYGON, 255 245 ['in', 'brunnel', 'bridge'], 256 246 ], 257 247 'layout': {}, 258 - 'paint': { 'fill-color': 'hsl(47, 26%, 88%)', 'fill-opacity': 0.5 }, 248 + 'paint': { 'fill-color': BLUE_GRAY, 'fill-opacity': 0.5 }, 259 249 }, 260 250 { 261 251 'id': `${sourceName}-road_path`, 262 252 'type': 'line', 263 253 'source': sourceName, 264 254 'source-layer': 'transportation', 265 - 'filter': [ 266 - 'all', 267 - ['==', '$type', 'LineString'], 268 - ['in', 'class', 'path', 'track'], 269 - ], 255 + 'filter': [ALL, LINE, ['in', 'class', 'path', 'track']], 270 256 'layout': { 'line-cap': 'square', 'line-join': 'bevel' }, 271 257 'paint': { 272 258 'line-color': 'hsl(0, 0%, 97%)', ··· 275 261 }, 276 262 }, 277 263 { 278 - 'id': `${sourceName}-road_minor`, 279 - 'type': 'line', 280 - 'source': sourceName, 281 - 'source-layer': 'transportation', 282 - 'minzoom': 13, 283 - 'filter': [ 284 - 'all', 285 - ['==', '$type', 'LineString'], 286 - ['in', 'class', 'minor', 'service'], 287 - ], 288 - 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 289 - 'paint': { 290 - 'line-color': 'hsl(0, 0%, 97%)', 291 - 'line-width': { 'base': 1.55, 'stops': [[4, 0.25], [20, 30]] }, 292 - }, 293 - }, 294 - { 295 - 'id': `${sourceName}-tunnel_minor`, 264 + 'id': `${sourceName}-tunnel_road`, 296 265 'type': 'line', 297 266 'source': sourceName, 298 267 'source-layer': 'transportation', 299 268 'filter': [ 300 - 'all', 301 - ['==', '$type', 'LineString'], 269 + ALL, 270 + LINE, 302 271 ['==', 'brunnel', 'tunnel'], 303 - ['==', 'class', 'minor_road'], 272 + ['in', ['get', 'class'], ['literal', MINOR_ROAD]], 304 273 ], 305 274 'layout': { 'line-cap': 'butt', 'line-join': 'miter' }, 306 275 'paint': { 307 - 'line-color': '#efefef', 308 - 'line-dasharray': [0.36, 0.18], 309 - 'line-width': { 'base': 1.55, 'stops': [[4, 0.25], [20, 30]] }, 310 - }, 311 - }, 312 - { 313 - 'id': `${sourceName}-tunnel_major`, 314 - 'type': 'line', 315 - 'source': sourceName, 316 - 'source-layer': 'transportation', 317 - 'filter': [ 318 - 'all', 319 - ['==', '$type', 'LineString'], 320 - ['==', 'brunnel', 'tunnel'], 321 - ['in', 'class', 'primary', 'secondary', 'tertiary', 'trunk'], 322 - ], 323 - 'layout': { 'line-cap': 'butt', 'line-join': 'miter' }, 324 - 'paint': { 325 - 'line-color': '#fff', 276 + 'line-color': [ 277 + 'match', 278 + ['get', 'class'], 279 + 'minor_road', 280 + '#efefef', 281 + '#fff', 282 + ], 326 283 'line-dasharray': [0.28, 0.14], 327 284 'line-width': { 'base': 1.4, 'stops': [[6, 0.5], [20, 30]] }, 328 285 }, ··· 335 292 'source-layer': 'aeroway', 336 293 'minzoom': 4, 337 294 'filter': [ 338 - 'all', 339 - ['==', '$type', 'Polygon'], 295 + ALL, 296 + POLYGON, 340 297 ['in', 'class', 'runway', 'taxiway'], 341 298 ], 342 299 'layout': { 'visibility': 'visible' }, ··· 353 310 'source-layer': 'aeroway', 354 311 'minzoom': 12, 355 312 'filter': [ 356 - 'all', 313 + ALL, 357 314 ['in', 'class', 'taxiway'], 358 - ['==', '$type', 'LineString'], 315 + LINE, 359 316 ], 360 317 'layout': { 361 318 'line-cap': 'round', ··· 376 333 'source-layer': 'aeroway', 377 334 'minzoom': 4, 378 335 'filter': [ 379 - 'all', 336 + ALL, 380 337 ['in', 'class', 'runway'], 381 - ['==', '$type', 'LineString'], 338 + LINE, 382 339 ], 383 340 'layout': { 384 341 'line-cap': 'round', ··· 391 348 'line-width': { 'base': 1.5, 'stops': [[11, 4], [17, 50]] }, 392 349 }, 393 350 }, 351 + // Roads — merged minor through motorway. All use white/near-white fill. 352 + // Per-class line-width controls visual hierarchy. Minor/service roads use 353 + // text-size 0 trick (line-width 0) below z13 to replicate the old minzoom. 394 354 { 395 - 'id': `${sourceName}-road_trunk_primary`, 355 + 'id': `${sourceName}-road`, 396 356 'type': 'line', 397 357 'source': sourceName, 398 358 'source-layer': 'transportation', 399 359 'filter': [ 400 - 'all', 401 - ['==', '$type', 'LineString'], 402 - ['in', 'class', 'trunk', 'primary'], 360 + ALL, 361 + LINE, 362 + [ 363 + 'in', 364 + ['get', 'class'], 365 + [ 366 + 'literal', 367 + [ 368 + 'motorway', 369 + 'trunk', 370 + 'primary', 371 + 'secondary', 372 + 'tertiary', 373 + 'minor', 374 + 'service', 375 + ], 376 + ], 377 + ], 378 + ['!has', 'brunnel'], 403 379 ], 404 380 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 405 381 'paint': { 406 382 'line-color': '#fff', 407 - 'line-width': { 'base': 1.4, 'stops': [[6, 0.5], [20, 30]] }, 408 - }, 409 - }, 410 - { 411 - 'id': `${sourceName}-road_secondary_tertiary`, 412 - 'type': 'line', 413 - 'source': sourceName, 414 - 'source-layer': 'transportation', 415 - 'filter': [ 416 - 'all', 417 - ['==', '$type', 'LineString'], 418 - ['in', 'class', 'secondary', 'tertiary'], 419 - ], 420 - 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 421 - 'paint': { 422 - 'line-color': '#fff', 423 - 'line-width': { 'base': 1.4, 'stops': [[6, 0.5], [20, 20]] }, 424 - }, 425 - }, 426 - { 427 - 'id': `${sourceName}-road_major_motorway`, 428 - 'type': 'line', 429 - 'source': sourceName, 430 - 'source-layer': 'transportation', 431 - 'filter': [ 432 - 'all', 433 - ['==', '$type', 'LineString'], 434 - ['==', 'class', 'motorway'], 435 - ], 436 - 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 437 - 'paint': { 438 - 'line-color': 'hsl(0, 0%, 100%)', 439 - 'line-offset': 0, 440 - 'line-width': { 'base': 1.4, 'stops': [[8, 1], [16, 10]] }, 441 - }, 442 - }, 443 - { 444 - 'id': `${sourceName}-railway-transit`, 445 - 'type': 'line', 446 - 'source': sourceName, 447 - 'source-layer': 'transportation', 448 - 'filter': [ 449 - 'all', 450 - ['==', 'class', 'transit'], 451 - ['!=', 'brunnel', 'tunnel'], 452 - ], 453 - 'layout': { 'visibility': 'visible' }, 454 - 'paint': { 455 - 'line-color': 'hsl(34, 12%, 66%)', 456 - 'line-opacity': { 'base': 1, 'stops': [[11, 0], [16, 1]] }, 383 + 'line-width': [ 384 + 'interpolate', 385 + ['exponential', 1.4], 386 + ['zoom'], 387 + 6, 388 + [ 389 + 'match', 390 + ['get', 'class'], 391 + 'motorway', 392 + 0.5, 393 + 'trunk', 394 + 0.5, 395 + 'primary', 396 + 0.5, 397 + 'secondary', 398 + 0.5, 399 + 'tertiary', 400 + 0.5, 401 + 0, 402 + ], 403 + 8, 404 + ['match', ['get', 'class'], 'motorway', 1, 0.5], 405 + 11, 406 + [ 407 + 'match', 408 + ['get', 'class'], 409 + 'motorway', 410 + 3, 411 + 'trunk', 412 + 2, 413 + 'primary', 414 + 2, 415 + 'secondary', 416 + 1.5, 417 + 'tertiary', 418 + 1.5, 419 + 0.15, 420 + ], 421 + 13, 422 + [ 423 + 'match', 424 + ['get', 'class'], 425 + 'motorway', 426 + 6, 427 + 'trunk', 428 + 4, 429 + 'primary', 430 + 4, 431 + 'secondary', 432 + 3, 433 + 'tertiary', 434 + 3, 435 + 1.5, 436 + ], 437 + 16, 438 + [ 439 + 'match', 440 + ['get', 'class'], 441 + 'motorway', 442 + 10, 443 + 'trunk', 444 + 14, 445 + 'primary', 446 + 14, 447 + 'secondary', 448 + 10, 449 + 'tertiary', 450 + 10, 451 + 12, 452 + ], 453 + 20, 454 + [ 455 + 'match', 456 + ['get', 'class'], 457 + 'motorway', 458 + 10, 459 + 'secondary', 460 + 20, 461 + 'tertiary', 462 + 20, 463 + 30, 464 + ], 465 + ], 457 466 }, 458 467 }, 459 468 { ··· 461 470 'type': 'line', 462 471 'source': sourceName, 463 472 'source-layer': 'transportation', 464 - 'filter': ['==', 'class', 'rail'], 465 - 'layout': { 'visibility': 'visible' }, 473 + 'filter': ['in', 'class', 'rail', 'transit'], 466 474 'paint': { 467 475 'line-color': 'hsl(34, 12%, 66%)', 468 - 'line-opacity': { 'base': 1, 'stops': [[11, 0], [16, 1]] }, 476 + 'line-opacity': ELEVEN_SIXTEEN, 469 477 }, 470 478 }, 471 479 { ··· 474 482 'source': sourceName, 475 483 'source-layer': 'waterway', 476 484 'filter': [ 477 - 'all', 478 - ['==', '$type', 'LineString'], 485 + ALL, 486 + LINE, 479 487 ['==', 'brunnel', 'bridge'], 480 488 ], 481 489 'layout': { 'line-cap': 'butt', 'line-join': 'miter' }, ··· 491 499 'source': sourceName, 492 500 'source-layer': 'waterway', 493 501 'filter': [ 494 - 'all', 495 - ['==', '$type', 'LineString'], 502 + ALL, 503 + LINE, 496 504 ['==', 'brunnel', 'bridge'], 497 505 ], 498 506 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 499 507 'paint': { 500 - 'line-color': 'hsl(205, 56%, 73%)', 508 + 'line-color': BLUE, 501 509 'line-width': { 'base': 1.55, 'stops': [[4, 0.25], [20, 30]] }, 502 510 }, 503 511 }, 504 512 { 505 - 'id': `${sourceName}-bridge_minor case`, 506 - 'type': 'line', 507 - 'source': sourceName, 508 - 'source-layer': 'transportation', 509 - 'filter': [ 510 - 'all', 511 - ['==', '$type', 'LineString'], 512 - ['==', 'brunnel', 'bridge'], 513 - ['==', 'class', 'minor_road'], 514 - ], 515 - 'layout': { 'line-cap': 'butt', 'line-join': 'miter' }, 516 - 'paint': { 517 - 'line-color': '#dedede', 518 - 'line-gap-width': { 'base': 1.55, 'stops': [[4, 0.25], [20, 30]] }, 519 - 'line-width': { 'base': 1.6, 'stops': [[12, 0.5], [20, 10]] }, 520 - }, 521 - }, 522 - { 523 - 'id': `${sourceName}-bridge_major case`, 513 + 'id': `${sourceName}-bridge_case`, 524 514 'type': 'line', 525 515 'source': sourceName, 526 516 'source-layer': 'transportation', 527 517 'filter': [ 528 - 'all', 529 - ['==', '$type', 'LineString'], 518 + ALL, 519 + LINE, 530 520 ['==', 'brunnel', 'bridge'], 531 - ['in', 'class', 'primary', 'secondary', 'tertiary', 'trunk'], 521 + ['in', ['get', 'class'], ['literal', MINOR_ROAD]], 532 522 ], 533 523 'layout': { 'line-cap': 'butt', 'line-join': 'miter' }, 534 524 'paint': { ··· 538 528 }, 539 529 }, 540 530 { 541 - 'id': `${sourceName}-bridge_minor`, 531 + 'id': `${sourceName}-bridge`, 542 532 'type': 'line', 543 533 'source': sourceName, 544 534 'source-layer': 'transportation', 545 535 'filter': [ 546 - 'all', 547 - ['==', '$type', 'LineString'], 536 + ALL, 537 + LINE, 548 538 ['==', 'brunnel', 'bridge'], 549 - ['==', 'class', 'minor_road'], 539 + [ 540 + 'in', 541 + ['get', 'class'], 542 + [ 543 + 'literal', 544 + MINOR_ROAD, 545 + ], 546 + ], 550 547 ], 551 548 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 552 549 'paint': { 553 - 'line-color': '#efefef', 554 - 'line-width': { 'base': 1.55, 'stops': [[4, 0.25], [20, 30]] }, 555 - }, 556 - }, 557 - { 558 - 'id': `${sourceName}-bridge_major`, 559 - 'type': 'line', 560 - 'source': sourceName, 561 - 'source-layer': 'transportation', 562 - 'filter': [ 563 - 'all', 564 - ['==', '$type', 'LineString'], 565 - ['==', 'brunnel', 'bridge'], 566 - ['in', 'class', 'primary', 'secondary', 'tertiary', 'trunk'], 567 - ], 568 - 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 569 - 'paint': { 570 - 'line-color': '#fff', 550 + 'line-color': [ 551 + 'match', 552 + ['get', 'class'], 553 + 'minor_road', 554 + '#efefef', 555 + '#fff', 556 + ], 571 557 'line-width': { 'base': 1.4, 'stops': [[6, 0.5], [20, 30]] }, 572 558 }, 573 559 }, ··· 591 577 'source': sourceName, 592 578 'source-layer': 'boundary', 593 579 'minzoom': 5, 594 - 'filter': [ 595 - 'all', 596 - ['<=', 'admin_level', 2], 597 - ['==', '$type', 'LineString'], 598 - ], 580 + 'filter': [ALL, ['<=', 'admin_level', 2], LINE], 599 581 'layout': { 600 582 'line-cap': 'round', 601 583 'line-join': 'round', ··· 611 593 'source': sourceName, 612 594 'source-layer': 'poi', 613 595 'minzoom': 15, 614 - 'filter': ['all', ['==', '$type', 'Point'], ['==', 'rank', 1]], 596 + 'filter': [ALL, ['==', '$type', 'Point'], ['==', 'rank', 1]], 615 597 'layout': { 616 598 'text-anchor': 'top', 617 599 'text-field': '{name:latin}', ··· 634 616 'source': sourceName, 635 617 'source-layer': 'aerodrome_label', 636 618 'minzoom': 11, 637 - 'filter': ['all', ['has', 'iata']], 619 + 'filter': [ALL, ['has', 'iata']], 638 620 'layout': { 639 621 'text-anchor': 'center', 640 622 'text-field': '{name:latin}', ··· 656 638 'source': sourceName, 657 639 'source-layer': 'transportation_name', 658 640 'minzoom': 14, 659 - 'filter': ['==', '$type', 'LineString'], 641 + 'filter': LINE, 660 642 'layout': { 661 643 'symbol-placement': 'line', 662 644 'text-field': '{name:latin}', ··· 683 665 'minzoom': 11, 684 666 'maxzoom': 16, 685 667 'filter': [ 686 - 'all', 668 + ALL, 687 669 ['==', '$type', 'Point'], 688 670 [ 689 671 'in', ··· 775 757 'source': sourceName, 776 758 'source-layer': 'place', 777 759 'maxzoom': 12, 778 - 'filter': ['all', ['==', '$type', 'Point'], ['==', 'class', 'country']], 760 + 'filter': [ALL, ['==', '$type', 'Point'], ['==', 'class', 'country']], 779 761 'layout': { 780 762 'text-field': '{name:latin}', 781 763 'text-font': [ ··· 797 779 }, 798 780 ] 799 781 } 782 + 783 + /** 784 + * MapLibre layer definitions for the OSM-based world basemap tiles. 785 + * 786 + * Source-layers emitted by tilemaker + process.world.lua / config.world.json: 787 + * water — ocean polygons (from coastline shapefile) + inland water 788 + * landcover — wood, wetland, sand, farmland, ice, rock, grass 789 + * landuse — school, hospital, military, residential, commercial, etc. 790 + * park — national_park, nature_reserve 791 + * boundary — administrative boundaries (admin_level 2–6) 792 + * waterway — named rivers 793 + * place — continent, country, state, city, town, village labels 794 + * mountain_peak — peaks and volcanoes 795 + * 796 + * Transportation is intentionally omitted — roads are distracting at world 797 + * zoom and are available in regional tiles once downloaded. 798 + * 799 + * Colors are harmonised with layers.ts so the visual transition when a 800 + * regional tile loads is as seamless as possible. 801 + */ 802 + export const WORLD_LAYERS = [ 803 + // ── Ocean / Water (fill) ────────────────────────────────────────────────── 804 + // fill-antialias matches regional water layer (no false here) 805 + { 806 + 'id': 'water', 807 + 'type': 'fill', 808 + 'source': 'world', 809 + 'source-layer': 'water', 810 + 'paint': { 'fill-color': BLUE }, 811 + }, 812 + 813 + // ── Landcover — colours match layers.ts exactly ─────────────────────────── 814 + { 815 + 'id': 'landcover', 816 + 'type': 'fill', 817 + 'source': 'world', 818 + 'source-layer': 'landcover', 819 + 'filter': [ 820 + 'in', 821 + ['get', 'class'], 822 + ['literal', ['grass', 'wood', 'wetland', 'sand', 'rock', 'ice']], 823 + ], 824 + 'paint': { 825 + 'fill-color': [ 826 + 'match', 827 + ['get', 'class'], 828 + 'grass', 829 + 'hsl(82, 46%, 72%)', 830 + 'wood', 831 + 'hsl(82, 46%, 72%)', 832 + 'wetland', 833 + 'hsl(180, 25%, 78%)', 834 + 'sand', 835 + 'rgba(232, 214, 38, 1)', 836 + 'rock', 837 + 'hsl(30, 8%, 72%)', 838 + 'ice', 839 + 'hsl(47, 22%, 94%)', 840 + 'hsl(82, 46%, 72%)', 841 + ], 842 + 'fill-opacity': [ 843 + 'interpolate', 844 + ['linear'], 845 + ['zoom'], 846 + 0, 847 + [ 848 + 'match', 849 + ['get', 'class'], 850 + 'ice', 851 + 1, 852 + 'wood', 853 + 0.6, 854 + 'sand', 855 + 0.3, 856 + 'grass', 857 + 0.45, 858 + 0.5, 859 + ], 860 + 6, 861 + [ 862 + 'match', 863 + ['get', 'class'], 864 + 'ice', 865 + 0.75, 866 + 'wood', 867 + 0.6, 868 + 'sand', 869 + 0.3, 870 + 'grass', 871 + 0.45, 872 + 0.5, 873 + ], 874 + 9, 875 + [ 876 + 'match', 877 + ['get', 'class'], 878 + 'ice', 879 + 0.5, 880 + 'wood', 881 + 0.8, 882 + 'sand', 883 + 0.3, 884 + 'grass', 885 + 0.45, 886 + 0.5, 887 + ], 888 + ], 889 + }, 890 + }, 891 + 892 + // ── Parks / nature reserves ─────────────────────────────────────────────── 893 + { 894 + 'id': 'park', 895 + 'type': 'fill', 896 + 'source': 'world', 897 + 'source-layer': 'park', 898 + 'paint': { 899 + 'fill-color': '#E1EBB0', 900 + 'fill-opacity': { 'base': 1, 'stops': [[3, 0], [7, 0.75]] }, 901 + }, 902 + }, 903 + 904 + // ── Waterways ───────────────────────────────────────────────────────────── 905 + { 906 + 'id': 'waterway', 907 + 'type': 'line', 908 + 'source': 'world', 909 + 'source-layer': 'waterway', 910 + 'minzoom': 6, 911 + 'paint': { 912 + 'line-color': BLUE, 913 + 'line-opacity': 1, 914 + 'line-width': { 'base': 1.4, 'stops': [[6, 0.5], [9, 3]] }, 915 + }, 916 + }, 917 + 918 + // ── Boundaries ──────────────────────────────────────────────────────────── 919 + { 920 + 'id': 'boundary-state', 921 + 'type': 'line', 922 + 'source': 'world', 923 + 'source-layer': 'boundary', 924 + 'filter': ['all', ['>=', 'admin_level', 3], ['<=', 'admin_level', 6]], 925 + 'minzoom': 3, 926 + 'paint': { 927 + 'line-color': 'hsla(0, 0%, 60%, 0.5)', 928 + 'line-width': 0.5, 929 + 'line-dasharray': [2, 1], 930 + }, 931 + }, 932 + { 933 + 'id': 'boundary-country', 934 + 'type': 'line', 935 + 'source': 'world', 936 + 'source-layer': 'boundary', 937 + 'filter': ['==', 'admin_level', 2], 938 + 'paint': { 939 + 'line-color': 'hsl(0, 0%, 60%)', 940 + 'line-width': { 'base': 1.3, 'stops': [[0, 0.5], [4, 1], [7, 1.5]] }, 941 + }, 942 + }, 943 + 944 + // ── Place labels ────────────────────────────────────────────────────────── 945 + { 946 + 'id': 'place-continent', 947 + 'type': 'symbol', 948 + 'source': 'world', 949 + 'source-layer': 'place', 950 + 'filter': ['==', 'class', 'continent'], 951 + 'maxzoom': 3, 952 + 'layout': { 953 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 954 + 'text-font': ['Noto Sans Medium'], 955 + 'text-size': 13, 956 + 'text-transform': 'uppercase', 957 + 'text-letter-spacing': 0.1, 958 + }, 959 + 'paint': { 960 + 'text-color': 'hsl(0, 0%, 30%)', 961 + 'text-halo-color': 'rgba(255, 255, 255, 0.7)', 962 + 'text-halo-width': 1, 963 + }, 964 + }, 965 + { 966 + 'id': 'place-country', 967 + 'type': 'symbol', 968 + 'source': 'world', 969 + 'source-layer': 'place', 970 + 'filter': ['==', 'class', 'country'], 971 + 'maxzoom': 7, 972 + 'layout': { 973 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 974 + 'text-font': [ 975 + 'case', 976 + ['has', 'iso_a2'], 977 + ['literal', ['Noto Sans Medium']], 978 + ['literal', ['Noto Sans Regular']], 979 + ], 980 + 'text-max-width': 10, 981 + 'text-size': { 'stops': [[3, 12], [8, 22]] }, 982 + }, 983 + 'paint': { 984 + 'text-color': 'hsl(0, 0%, 13%)', 985 + 'text-halo-blur': 0, 986 + 'text-halo-color': 'rgba(255, 255, 255, 0.75)', 987 + 'text-halo-width': 2, 988 + }, 989 + }, 990 + { 991 + 'id': 'place-state', 992 + 'type': 'symbol', 993 + 'source': 'world', 994 + 'source-layer': 'place', 995 + 'filter': ['in', 'class', 'state', 'province'], 996 + 'minzoom': 4, 997 + 'maxzoom': 8, 998 + 'layout': { 999 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 1000 + 'text-font': ['Noto Sans Regular'], 1001 + 'text-size': 10, 1002 + 'text-transform': 'uppercase', 1003 + 'text-letter-spacing': 0.05, 1004 + 'text-max-width': 6, 1005 + }, 1006 + 'paint': { 1007 + 'text-color': 'hsl(0, 0%, 35%)', 1008 + 'text-halo-color': 'rgba(255, 255, 255, 0.6)', 1009 + 'text-halo-width': 1, 1010 + 'text-opacity': 0.8, 1011 + }, 1012 + }, 1013 + { 1014 + 'id': 'place-settlement', 1015 + 'type': 'symbol', 1016 + 'source': 'world', 1017 + 'source-layer': 'place', 1018 + 'filter': [ 1019 + 'in', 1020 + ['get', 'class'], 1021 + ['literal', ['city', 'town', 'village']], 1022 + ], 1023 + 'minzoom': 6, 1024 + 'layout': { 1025 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 1026 + 'text-font': ['Noto Sans Regular'], 1027 + 'text-max-width': ['match', ['get', 'class'], 'city', 10, 6], 1028 + 'text-size': [ 1029 + 'interpolate', 1030 + ['linear'], 1031 + ['zoom'], 1032 + 6, 1033 + ['match', ['get', 'class'], 'city', 11, 'town', 10, 0], 1034 + 8, 1035 + ['match', ['get', 'class'], 'city', 12, 'town', 11, 10], 1036 + 9, 1037 + ['match', ['get', 'class'], 'city', 15, 'town', 14, 14], 1038 + ], 1039 + }, 1040 + 'paint': { 1041 + 'text-color': [ 1042 + 'match', 1043 + ['get', 'class'], 1044 + 'city', 1045 + 'hsl(0, 0%, 0%)', 1046 + 'hsl(0, 0%, 25%)', 1047 + ], 1048 + 'text-halo-blur': 0, 1049 + 'text-halo-color': 'rgba(255, 255, 255, 0.75)', 1050 + 'text-halo-width': 2, 1051 + }, 1052 + }, 1053 + ]