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

Configure Feed

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

fix: worldmap generation

+646 -88
-1
.gitignore
··· 2 2 .DS_Store 3 3 dist 4 4 data/osm 5 - data/shapefiles
+37 -10
data/README.md
··· 1 1 # Data 2 2 3 - This directory holds intermediate files used to generate the `.pmtiles` map tile files served by the `www` app. Output tiles are written to `www/static/tiles/`. 3 + This directory holds intermediate files used to generate the `.pmtiles` map tile files served by the `www` app. Output tiles are written to `www/static/tiles/`. We don't really need suuuuuuper up-to-date maps for this, since we trim a lot of data; the tiles are mainly used as a way to orient the user as they create their own map. 4 4 5 5 All tile generation is done via the CLI: 6 6 ··· 16 16 - [`tilemaker`](https://github.com/systemed/tilemaker) — PBF → PMTiles conversion (`build`, `build:world`) 17 17 - [`osmium`](https://osmcode.org/osmium-tool/) — PBF extraction (`extract` command) 18 18 19 + The following resources are necessary to generate pmtiles: 20 + - [Download a planet `.osm.pbf`](https://wiki.openstreetmap.org/wiki/Planet.osm) for the world-level pmtiles (put it in `./osm/planet-latest.osm.pbf`). For me, using a torrentfile has been the most stable way. 21 + - Download regional .osm.pbf files for regions if you don't want to self-extract (can download with `download:osm`) 22 + - Coastlines WGS84 projection from https://osmdata.openstreetmap.de/data/coastlines.html (put it in `./cli/shared/tilemaker/coastline`) (todo for me: create a `download:coastlines` cli cmd) 23 + 19 24 ## CLI Commands 20 25 21 - | Command | Description | 22 - | ------------------------------------------ | ------------------------------------------------- | 23 - | `list [--search <term>]` | Browse available region slugs | 24 - | `download:osm <region> [--force]` | Download `.osm.pbf` from Geofabrik | 25 - | `download:poly [region] [--all] [--force]` | Download `.poly` boundary file(s) | 26 - | `extract <region> --from <source>` | Carve a sub-region from a larger PBF using osmium | 27 - | `build <region>` | Convert `.osm.pbf` → `.pmtiles` using tilemaker | 28 - | `build:world` | Build world-scale basemap tiles | 29 - | `clean --region <slug> \| --all` | Remove intermediate files | 26 + | Command | Description | 27 + | ------------------------------------------ | ------------------------------------------------------------------ | 28 + | `list [--search <term>]` | Browse available region slugs | 29 + | `download:osm <region> [--force]` | Download `.osm.pbf` from Geofabrik | 30 + | `download:poly [region] [--all] [--force]` | Download `.poly` boundary file(s) | 31 + | `extract <region> --from <source>` | Carve a sub-region from a larger PBF using osmium | 32 + | `build <region>` | Convert `.osm.pbf` → `.pmtiles` using tilemaker | 33 + | `trim:world` | Strip planet PBF to only data needed for world tiles (saves memory)| 34 + | `build:world` | Build world-scale basemap tiles | 35 + | `clean --region <slug> \| --all` | Remove intermediate files | 30 36 31 37 ## World Tiles 32 38 33 39 The world basemap is built from OSM data using a tilemaker pipeline, sourced from a full planet PBF. Place `planet-latest.osm.pbf` in `data/osm/` and run: 34 40 35 41 ```sh 42 + deno task data trim:world # recommended first step — see below 36 43 deno task data build:world [--maxzoom <5|7|9>] # default maxzoom: 7 37 44 ``` 38 45 ··· 42 49 - `data/cli/shared/tilemaker/process.world.lua` — feature processing logic 43 50 44 51 Note: this takes a super long time to run. 52 + 53 + ### Trimming the planet file (recommended) 54 + 55 + The full planet PBF (~75 GB) contains a huge amount of data that is irrelevant at world zoom levels — buildings, addresses, shop/amenity POIs, minor roads, etc. Running `trim:world` uses `osmium tags-filter` to produce a much smaller `planet-trimmed.osm.pbf` containing only the tags that `process.world.lua` actually reads: 56 + 57 + - Place nodes (countries, states, cities, towns, villages) 58 + - Natural peaks and volcanoes 59 + - Administrative boundaries 60 + - Major roads (motorway → secondary) and ferry routes 61 + - Main-line railways 62 + - Rivers, waterways, water polygons 63 + - Landcover and landuse polygons 64 + - Parks and nature reserves 65 + 66 + ```sh 67 + deno task data trim:world # creates data/osm/planet-trimmed.osm.pbf 68 + deno task data trim:world --overwrite # re-run and replace an existing trimmed file 69 + ``` 70 + 71 + When `planet-trimmed.osm.pbf` is present, `build:world` will automatically use it instead of the full planet file. This significantly reduces tilemaker's peak memory usage and overall build time. 45 72 46 73 ## Regional Tiles 47 74
+20 -3
data/cli/commands/build-world.ts
··· 10 10 import { run } from '../shared/run.ts' 11 11 12 12 const PLANET_PBF = join(PBF_DIR, 'planet-latest.osm.pbf') 13 + const PLANET_TRIMMED_PBF = join(PBF_DIR, 'planet-trimmed.osm.pbf') 13 14 14 15 export const buildWorldCmd = new Command() 15 16 .name('build:world') ··· 21 22 default: 7, 22 23 }) 23 24 .action(async ({ maxzoom }) => { 24 - if (!(await Deno.stat(PLANET_PBF).catch(() => null))) { 25 - throw new Error(`Planet PBF not found: ${PLANET_PBF}`) 25 + const hasTrimmed = !!(await Deno.stat(PLANET_TRIMMED_PBF).catch(() => null)) 26 + const hasPlanet = !!(await Deno.stat(PLANET_PBF).catch(() => null)) 27 + 28 + if (!hasTrimmed && !hasPlanet) { 29 + throw new Error( 30 + `Planet PBF not found: ${PLANET_PBF}\n` + 31 + `Run 'deno task data trim:world' first to create a trimmed copy, or place the full planet file at that path.`, 32 + ) 33 + } 34 + 35 + const inputPbf = hasTrimmed ? PLANET_TRIMMED_PBF : PLANET_PBF 36 + if (hasTrimmed) { 37 + console.log(`Using trimmed planet file: ${PLANET_TRIMMED_PBF}`) 38 + } else { 39 + console.log( 40 + `No trimmed file found — using full planet: ${PLANET_PBF}\n` + 41 + `Tip: run 'deno task data trim:world' first to reduce memory usage.`, 42 + ) 26 43 } 27 44 28 45 await ensureDir(join(TILES_OUT_DIR, 'world')) ··· 46 63 try { 47 64 await run('tilemaker', [ 48 65 '--input', 49 - PLANET_PBF, 66 + inputPbf, 50 67 '--output', 51 68 outPath, 52 69 '--config',
+196
data/cli/commands/trim-world.ts
··· 1 + import { Command } from '@cliffy/command' 2 + import { join } from '@std/path' 3 + import { PBF_DIR } from '../shared/paths.ts' 4 + import { run } from '../shared/run.ts' 5 + 6 + const PLANET_PBF = join(PBF_DIR, 'planet-latest.osm.pbf') 7 + const TRIMMED_PBF = join(PBF_DIR, 'planet-trimmed.osm.pbf') 8 + 9 + /** 10 + * osmium tags-filter expressions covering every tag read by process.world.lua. 11 + * 12 + * Format: <object-type>/<key>=<value> or <object-type>/<key> 13 + * n = node, w = way, r = relation, a = area (way or relation) 14 + * 15 + * We intentionally omit buildings, addresses, shop/amenity POIs, minor roads, 16 + * and anything else that only appears at z12+ in the regional tile pipeline. 17 + */ 18 + const FILTER_EXPRESSIONS = ` 19 + # ── Nodes ────────────────────────────────────────────────────────────────── 20 + # place nodes (countries, states, cities, towns, villages) 21 + n/place=continent 22 + n/place=country 23 + n/place=state 24 + n/place=province 25 + n/place=city 26 + n/place=town 27 + n/place=village 28 + 29 + # natural peaks & volcanoes 30 + n/natural=peak 31 + n/natural=volcano 32 + 33 + # ── Ways ─────────────────────────────────────────────────────────────────── 34 + # Administrative boundaries 35 + w/boundary=administrative 36 + w/boundary=national_park 37 + 38 + # Major roads only (motorway, trunk, primary, secondary + their links) 39 + w/highway=motorway 40 + w/highway=motorway_link 41 + w/highway=trunk 42 + w/highway=trunk_link 43 + w/highway=primary 44 + w/highway=primary_link 45 + w/highway=secondary 46 + w/highway=secondary_link 47 + 48 + # Ferry routes 49 + w/route=ferry 50 + 51 + # Main-line railways 52 + w/railway=rail 53 + w/railway=narrow_gauge 54 + w/railway=preserved 55 + w/railway=funicular 56 + 57 + # Rivers & named waterways 58 + w/waterway=river 59 + w/waterway=canal 60 + 61 + # Water polygons 62 + w/natural=water 63 + w/water 64 + 65 + # Landcover 66 + w/natural=wood 67 + w/natural=wetland 68 + w/natural=beach 69 + w/natural=sand 70 + w/natural=dune 71 + w/natural=glacier 72 + w/natural=ice_shelf 73 + w/natural=bare_rock 74 + w/natural=scree 75 + w/natural=fell 76 + w/natural=grassland 77 + w/natural=grass 78 + w/natural=heath 79 + w/natural=meadow 80 + w/natural=scrub 81 + w/natural=shrubbery 82 + w/natural=tundra 83 + w/natural=bay 84 + 85 + # Landuse (landcover-mapped keys) 86 + w/landuse=forest 87 + w/landuse=farmland 88 + w/landuse=farm 89 + w/landuse=orchard 90 + w/landuse=vineyard 91 + w/landuse=allotments 92 + w/landuse=village_green 93 + w/landuse=recreation_ground 94 + 95 + # Landuse (landuse layer keys) 96 + w/landuse=school 97 + w/landuse=university 98 + w/landuse=hospital 99 + w/landuse=railway 100 + w/landuse=cemetery 101 + w/landuse=military 102 + w/landuse=residential 103 + w/landuse=commercial 104 + w/landuse=industrial 105 + w/landuse=retail 106 + w/landuse=stadium 107 + w/landuse=pitch 108 + 109 + # Leisure (landcover + park) 110 + w/leisure=park 111 + w/leisure=garden 112 + w/leisure=nature_reserve 113 + w/leisure=stadium 114 + w/leisure=pitch 115 + 116 + # Parks / nature reserves via boundary 117 + w/boundary=national_park 118 + w/leisure=nature_reserve 119 + 120 + # Place islands (rendered as centroids) 121 + w/place=island 122 + 123 + # ── Relations ────────────────────────────────────────────────────────────── 124 + # Administrative boundaries (required for boundary layer) 125 + r/type=boundary 126 + r/boundary=administrative 127 + r/boundary=national_park 128 + r/leisure=nature_reserve 129 + `.trim() 130 + 131 + export const trimWorldCmd = new Command() 132 + .name('trim:world') 133 + .description( 134 + 'Trim planet-latest.osm.pbf to only the data needed for world basemap tiles ' + 135 + '(removes buildings, POIs, minor roads, addresses, etc.). ' + 136 + `Output: ${TRIMMED_PBF}`, 137 + ) 138 + .option('--overwrite', 'Overwrite existing trimmed PBF if it exists.', { 139 + default: false, 140 + }) 141 + .action(async ({ overwrite }) => { 142 + // Check source file exists 143 + if (!(await Deno.stat(PLANET_PBF).catch(() => null))) { 144 + throw new Error( 145 + `Planet PBF not found: ${PLANET_PBF}\n` + 146 + `Download it from https://planet.openstreetmap.org and place it at that path.`, 147 + ) 148 + } 149 + 150 + // Check output doesn't already exist unless --overwrite 151 + if (!overwrite && (await Deno.stat(TRIMMED_PBF).catch(() => null))) { 152 + console.log(`Trimmed PBF already exists: ${TRIMMED_PBF}`) 153 + console.log(`Use --overwrite to replace it.`) 154 + Deno.exit(0) 155 + } 156 + 157 + // Write expressions to a temp file so we don't hit shell argument limits 158 + // and so the list stays readable. 159 + const tmpExpressions = join(PBF_DIR, '.trim-world-expressions.tmp') 160 + await Deno.writeTextFile(tmpExpressions, FILTER_EXPRESSIONS) 161 + 162 + const { size: inputSize } = await Deno.stat(PLANET_PBF) 163 + const inputSizeStr = (inputSize / 1024 ** 3).toFixed(1) 164 + console.log(`Input: ${PLANET_PBF} (${inputSizeStr} GB)`) 165 + console.log(`Output: ${TRIMMED_PBF}`) 166 + console.log(`Running osmium tags-filter — this will take a while...`) 167 + 168 + try { 169 + await run('osmium', [ 170 + 'tags-filter', 171 + '--progress', 172 + '--expressions', 173 + tmpExpressions, 174 + '--output', 175 + TRIMMED_PBF, 176 + ...(overwrite ? ['--overwrite'] : []), 177 + PLANET_PBF, 178 + ]) 179 + } finally { 180 + await Deno.remove(tmpExpressions).catch(() => {}) 181 + } 182 + 183 + const { size: outputSize } = await Deno.stat(TRIMMED_PBF) 184 + const outputSizeStr = outputSize < 1024 ** 3 185 + ? `${Math.round(outputSize / 1024 ** 2)} MB` 186 + : `${(outputSize / 1024 ** 3).toFixed(1)} GB` 187 + const reduction = (((inputSize - outputSize) / inputSize) * 100).toFixed(0) 188 + 189 + console.log(`\nDone!`) 190 + console.log(`Output: ${TRIMMED_PBF} (${outputSizeStr})`) 191 + console.log(`Reduced planet by ~${reduction}%.`) 192 + console.log( 193 + `\nNext step: deno task data build:world\n` + 194 + `(build:world will automatically use the trimmed file when present)`, 195 + ) 196 + })
+2
data/cli/main.ts
··· 21 21 import { extractCmd } from './commands/extract.ts' 22 22 import { buildCmd } from './commands/build.ts' 23 23 import { buildWorldCmd } from './commands/build-world.ts' 24 + import { trimWorldCmd } from './commands/trim-world.ts' 24 25 import { cleanCmd } from './commands/clean.ts' 25 26 26 27 await new Command() ··· 36 37 .command('extract', extractCmd) 37 38 .command('build', buildCmd) 38 39 .command('build:world', buildWorldCmd) 40 + .command('trim:world', trimWorldCmd) 39 41 .command('clean', cleanCmd) 40 42 .parse(Deno.args)
+1 -1
data/cli/shared/tilemaker/config.world.json
··· 36 36 "ocean": { 37 37 "minzoom": 0, 38 38 "maxzoom": 9, 39 - "source": "../../../shapefiles/water_polygons.shp", 39 + "source": "./data/cli/shared/tilemaker/coastline/water_polygons.shp", 40 40 "filter_below": 9, 41 41 "filter_area": 0.5, 42 42 "simplify_below": 8,
+14 -5
www/routes/map.ts
··· 68 68 style: { 69 69 version: 8, 70 70 glyphs: '/static/basemaps-assets/fonts/{fontstack}/{range}.pbf', 71 - layers: [], 71 + layers: [ 72 + { 73 + id: 'background', 74 + type: 'background', 75 + paint: { 'background-color': 'hsl(47, 26%, 88%)' }, 76 + }, 77 + ], 72 78 sources: {}, 73 79 }, 74 80 }) ··· 367 373 368 374 async #loadWorldTiles(): Promise<void> { 369 375 if (!this.#map) return 376 + let worldFilename = 'world_z5.pmtiles' 370 377 if (!registeredSources.has('world')) { 371 378 const z7 = await getCachedPMTiles('world_z7.pmtiles') 372 379 const z6 = !z7 ? await getCachedPMTiles('world_z6.pmtiles') : null 380 + if (z7) worldFilename = 'world_z7.pmtiles' 381 + else if (z6) worldFilename = 'world_z6.pmtiles' 373 382 const pmtiles = z7 ?? z6 ?? await downloadAndSavePMTiles( 374 - '/static/tiles/world/world.pmtiles', 375 - 'world.pmtiles', 383 + '/static/tiles/world/world_z5.pmtiles', 384 + 'world_z5.pmtiles', 376 385 ) 377 386 protocol.add(pmtiles) 378 387 registeredSources.add('world') ··· 380 389 if (!this.#map.getSource('world')) { 381 390 this.#map.addSource('world', { 382 391 type: 'vector', 383 - url: 'pmtiles://world.pmtiles', 384 - attribution: 'Natural Earth', 392 + url: `pmtiles://${worldFilename}`, 393 + attribution: '© OpenStreetMap contributors', 385 394 }) 386 395 worldLayers.forEach((layer) => 387 396 this.#map!.addLayer(layer as maplibregl.AddLayerObject)
+58 -54
www/utils/layers.ts
··· 226 226 'metadata': {}, 227 227 'source': sourceName, 228 228 'source-layer': 'transportation', 229 + 'minzoom': 13, 229 230 'filter': ['all', ['==', '$type', 'Polygon'], ['==', 'class', 'pier']], 230 231 'layout': { 'visibility': 'visible' }, 231 232 'paint': { 'fill-antialias': true, 'fill-color': 'hsl(47, 26%, 88%)' }, ··· 236 237 'metadata': {}, 237 238 'source': sourceName, 238 239 'source-layer': 'transportation', 240 + 'minzoom': 13, 239 241 'filter': ['all', ['==', '$type', 'LineString'], ['in', 'class', 'pier']], 240 242 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 241 243 'paint': { ··· 248 250 'type': 'fill', 249 251 'source': sourceName, 250 252 'source-layer': 'transportation', 253 + 'minzoom': 12, 251 254 'filter': [ 252 255 'all', 253 256 ['==', '$type', 'Polygon'], ··· 582 585 'line-dasharray': [2, 1], 583 586 }, 584 587 }, 588 + // Country boundaries — split into two layers matching world_layers.ts so 589 + // there is no visual jump when regional tiles load over the world basemap. 585 590 { 586 591 'id': `${sourceName}-admin_country_z0-4`, 587 592 'type': 'line', ··· 631 636 'type': 'symbol', 632 637 'source': sourceName, 633 638 'source-layer': 'poi', 634 - 'minzoom': 14, 639 + 'minzoom': 15, 635 640 'filter': ['all', ['==', '$type', 'Point'], ['==', 'rank', 1]], 636 641 'layout': { 637 - 'icon-size': 1, 638 642 'text-anchor': 'top', 639 - 'text-field': '{name:latin}\n{name:nonlatin}', 643 + 'text-field': '{name:latin}', 640 644 'text-font': ['Noto Sans Regular'], 641 645 'text-max-width': 8, 642 646 'text-offset': [0, 0.5], ··· 655 659 'type': 'symbol', 656 660 'source': sourceName, 657 661 'source-layer': 'aerodrome_label', 658 - 'minzoom': 10, 662 + 'minzoom': 11, 659 663 'filter': ['all', ['has', 'iata']], 660 664 'layout': { 661 - 'icon-size': 1, 662 - 'text-anchor': 'top', 663 - 'text-field': '{name:latin}\n{name:nonlatin}', 665 + 'text-anchor': 'center', 666 + 'text-field': '{name:latin}', 664 667 'text-font': ['Noto Sans Regular'], 665 668 'text-max-width': 8, 666 - 'text-offset': [0, 0.5], 667 669 'text-size': 11, 668 670 'visibility': 'visible', 669 671 }, ··· 679 681 'type': 'symbol', 680 682 'source': sourceName, 681 683 'source-layer': 'transportation_name', 682 - 'minzoom': 13, 684 + 'minzoom': 14, 683 685 'filter': ['==', '$type', 'LineString'], 684 686 'layout': { 685 687 'symbol-placement': 'line', 686 - 'text-field': '{name:latin} {name:nonlatin}', 688 + 'text-field': '{name:latin}', 687 689 'text-font': ['Noto Sans Regular'], 688 - 'text-letter-spacing': 0.1, 690 + 'text-letter-spacing': 0.05, 689 691 'text-rotation-alignment': 'map', 690 - 'text-size': { 'base': 1.4, 'stops': [[10, 8], [20, 14]] }, 691 - 'text-transform': 'uppercase', 692 + 'text-size': { 'base': 1, 'stops': [[13, 10], [16, 12]] }, 692 693 'visibility': 'visible', 693 694 }, 694 695 'paint': { 695 - 'text-color': '#000', 696 + 'text-color': 'hsl(0, 0%, 30%)', 696 697 'text-halo-color': 'hsl(0, 0%, 100%)', 697 - 'text-halo-width': 2, 698 + 'text-halo-width': 1.5, 698 699 }, 699 700 }, 701 + // Towns: show from z11, fade out before city labels dominate 700 702 { 701 - 'id': `${sourceName}-place_label_other`, 703 + 'id': `${sourceName}-place_label_town`, 702 704 'type': 'symbol', 703 705 'source': sourceName, 704 706 'source-layer': 'place', 705 - 'minzoom': 8, 706 - 'filter': [ 707 - 'all', 708 - ['==', '$type', 'Point'], 709 - ['!in', 'class', 'city', 'state', 'country', 'continent'], 710 - ], 707 + 'minzoom': 11, 708 + 'maxzoom': 14, 709 + 'filter': ['all', ['==', '$type', 'Point'], ['==', 'class', 'town']], 711 710 'layout': { 712 711 'text-anchor': 'center', 713 - 'text-field': '{name:latin}\n{name:nonlatin}', 712 + 'text-field': '{name:latin}', 714 713 'text-font': ['Noto Sans Regular'], 715 - 'text-max-width': 6, 716 - 'text-size': { 'stops': [[6, 10], [12, 14]] }, 714 + 'text-max-width': 8, 715 + 'text-size': { 'stops': [[11, 11], [14, 13]] }, 717 716 'visibility': 'visible', 718 717 }, 719 718 'paint': { 720 719 'text-color': 'hsl(0, 0%, 25%)', 721 720 'text-halo-blur': 0, 722 721 'text-halo-color': 'hsl(0, 0%, 100%)', 723 - 'text-halo-width': 2, 722 + 'text-halo-width': 1.5, 724 723 }, 725 724 }, 725 + // Suburbs & neighbourhoods: only at high zoom so they don't crowd city view 726 726 { 727 - 'id': `${sourceName}-place_label_city`, 727 + 'id': `${sourceName}-place_label_suburb`, 728 728 'type': 'symbol', 729 729 'source': sourceName, 730 730 'source-layer': 'place', 731 + 'minzoom': 13, 731 732 'maxzoom': 16, 732 - 'filter': ['all', ['==', '$type', 'Point'], ['==', 'class', 'city']], 733 + 'filter': [ 734 + 'all', 735 + ['==', '$type', 'Point'], 736 + ['in', 'class', 'suburb', 'neighbourhood', 'quarter'], 737 + ], 733 738 'layout': { 734 - 'text-field': '{name:latin}\n{name:nonlatin}', 739 + 'text-anchor': 'center', 740 + 'text-field': '{name:latin}', 735 741 'text-font': ['Noto Sans Regular'], 736 - 'text-max-width': 10, 737 - 'text-size': { 'stops': [[3, 12], [8, 16]] }, 742 + 'text-max-width': 6, 743 + 'text-size': { 'stops': [[13, 10], [16, 12]] }, 744 + 'text-transform': 'uppercase', 745 + 'text-letter-spacing': 0.05, 746 + 'visibility': 'visible', 738 747 }, 739 748 'paint': { 740 - 'text-color': 'hsl(0, 0%, 0%)', 749 + 'text-color': 'hsl(0, 0%, 45%)', 741 750 'text-halo-blur': 0, 742 - 'text-halo-color': 'hsla(0, 0%, 100%, 0.75)', 743 - 'text-halo-width': 2, 751 + 'text-halo-color': 'hsl(0, 0%, 100%)', 752 + 'text-halo-width': 1, 744 753 }, 745 754 }, 746 755 { 747 - 'id': `${sourceName}-country_label-other`, 756 + 'id': `${sourceName}-place_label_city`, 748 757 'type': 'symbol', 749 758 'source': sourceName, 750 759 'source-layer': 'place', 751 - 'maxzoom': 12, 752 - 'filter': [ 753 - 'all', 754 - ['==', '$type', 'Point'], 755 - ['==', 'class', 'country'], 756 - ['!has', 'iso_a2'], 757 - ], 760 + 'minzoom': 9, 761 + 'maxzoom': 16, 762 + 'filter': ['all', ['==', '$type', 'Point'], ['==', 'class', 'city']], 758 763 'layout': { 759 764 'text-field': '{name:latin}', 760 - 'text-font': ['Noto Sans Regular'], 765 + 'text-font': ['Noto Sans Bold'], 761 766 'text-max-width': 10, 762 - 'text-size': { 'stops': [[3, 12], [8, 22]] }, 763 - 'visibility': 'visible', 767 + 'text-size': { 'stops': [[9, 12], [13, 16]] }, 764 768 }, 765 769 'paint': { 766 - 'text-color': 'hsl(0, 0%, 13%)', 770 + 'text-color': 'hsl(0, 0%, 0%)', 767 771 'text-halo-blur': 0, 768 - 'text-halo-color': 'rgba(255,255,255,0.75)', 772 + 'text-halo-color': 'hsla(0, 0%, 100%, 0.75)', 769 773 'text-halo-width': 2, 770 774 }, 771 775 }, ··· 775 779 'source': sourceName, 776 780 'source-layer': 'place', 777 781 'maxzoom': 12, 778 - 'filter': [ 779 - 'all', 780 - ['==', '$type', 'Point'], 781 - ['==', 'class', 'country'], 782 - ['has', 'iso_a2'], 783 - ], 782 + 'filter': ['all', ['==', '$type', 'Point'], ['==', 'class', 'country']], 784 783 'layout': { 785 784 'text-field': '{name:latin}', 786 - 'text-font': ['Noto Sans Bold'], 785 + 'text-font': [ 786 + 'case', 787 + ['has', 'iso_a2'], 788 + ['literal', ['Noto Sans Bold']], 789 + ['literal', ['Noto Sans Regular']], 790 + ], 787 791 'text-max-width': 10, 788 792 'text-size': { 'stops': [[3, 12], [8, 22]] }, 789 793 'visibility': 'visible',
+318 -14
www/utils/world_layers.ts
··· 1 + /** 2 + * MapLibre layer definitions for the OSM-based world basemap tiles. 3 + * 4 + * Source-layers emitted by tilemaker + process.world.lua / config.world.json: 5 + * water — ocean polygons (from coastline shapefile) + inland water 6 + * landcover — wood, wetland, sand, farmland, ice, rock, grass 7 + * landuse — school, hospital, military, residential, commercial, etc. 8 + * park — national_park, nature_reserve 9 + * boundary — administrative boundaries (admin_level 2–6) 10 + * waterway — named rivers 11 + * place — continent, country, state, city, town, village labels 12 + * mountain_peak — peaks and volcanoes 13 + * 14 + * Transportation is intentionally omitted — roads are distracting at world 15 + * zoom and are available in regional tiles once downloaded. 16 + * 17 + * Colors are harmonised with layers.ts so the visual transition when a 18 + * regional tile loads is as seamless as possible. 19 + */ 1 20 export default [ 21 + // ── Ocean / Water (fill) ────────────────────────────────────────────────── 22 + // fill-antialias matches regional water layer (no false here) 2 23 { 3 - 'id': 'boundary-lines', 4 - 'type': 'line', 24 + 'id': 'water', 25 + 'type': 'fill', 26 + 'source': 'world', 27 + 'source-layer': 'water', 28 + 'paint': { 29 + 'fill-color': 'hsl(205, 56%, 73%)', 30 + }, 31 + }, 32 + 33 + // ── Landcover — colours match layers.ts exactly ─────────────────────────── 34 + { 35 + 'id': 'landcover-grass', 36 + 'type': 'fill', 37 + 'source': 'world', 38 + 'source-layer': 'landcover', 39 + 'filter': ['==', 'class', 'grass'], 40 + 'paint': { 41 + 'fill-color': 'hsl(82, 46%, 72%)', 42 + 'fill-opacity': 0.45, 43 + }, 44 + }, 45 + { 46 + 'id': 'landcover-wood', 47 + 'type': 'fill', 5 48 'source': 'world', 6 - 'source-layer': 'ne_10m_admin_0_boundary_lines_land', 49 + 'source-layer': 'landcover', 50 + 'filter': ['==', 'class', 'wood'], 7 51 'paint': { 8 - 'line-color': '#888', 9 - 'line-width': 1, 52 + 'fill-color': 'hsl(82, 46%, 72%)', 53 + 'fill-opacity': { 'base': 1, 'stops': [[6, 0.6], [9, 0.8]] }, 10 54 }, 11 55 }, 12 56 { 13 - 'id': 'countries', 57 + 'id': 'landcover-farmland', 14 58 'type': 'fill', 15 59 'source': 'world', 16 - 'source-layer': 'ne_10m_admin_0_countries', 60 + 'source-layer': 'landcover', 61 + 'filter': ['==', 'class', 'farmland'], 17 62 'paint': { 18 - 'fill-color': '#ccc', 19 - 'fill-outline-color': '#888', 63 + 'fill-color': '#eae0d0', 64 + 'fill-opacity': 0.7, 20 65 }, 21 66 }, 22 67 { 23 - 'id': 'states', 68 + 'id': 'landcover-wetland', 24 69 'type': 'fill', 25 70 'source': 'world', 26 - 'source-layer': 'ne_10m_admin_1_states_provinces', 71 + 'source-layer': 'landcover', 72 + 'filter': ['==', 'class', 'wetland'], 27 73 'paint': { 28 - 'fill-color': '#ddd', 29 - 'fill-outline-color': '#888', 74 + 'fill-color': 'hsl(180, 25%, 78%)', 75 + 'fill-opacity': 0.5, 30 76 }, 31 - 'maxzoom': 5, 77 + }, 78 + { 79 + 'id': 'landcover-sand', 80 + 'type': 'fill', 81 + 'source': 'world', 82 + 'source-layer': 'landcover', 83 + 'filter': ['==', 'class', 'sand'], 84 + 'paint': { 85 + 'fill-antialias': false, 86 + 'fill-color': 'rgba(232, 214, 38, 1)', 87 + 'fill-opacity': 0.3, 88 + }, 89 + }, 90 + { 91 + 'id': 'landcover-rock', 92 + 'type': 'fill', 93 + 'source': 'world', 94 + 'source-layer': 'landcover', 95 + 'filter': ['==', 'class', 'rock'], 96 + 'paint': { 97 + 'fill-color': 'hsl(30, 8%, 72%)', 98 + 'fill-opacity': 0.5, 99 + }, 100 + }, 101 + { 102 + 'id': 'landcover-ice', 103 + 'type': 'fill', 104 + 'source': 'world', 105 + 'source-layer': 'landcover', 106 + 'filter': ['==', 'class', 'ice'], 107 + 'paint': { 108 + 'fill-color': 'hsl(47, 22%, 94%)', 109 + 'fill-opacity': { 'base': 1, 'stops': [[0, 1], [8, 0.5]] }, 110 + }, 111 + }, 112 + 113 + // ── Landuse ─────────────────────────────────────────────────────────────── 114 + { 115 + 'id': 'landuse-residential', 116 + 'type': 'fill', 117 + 'source': 'world', 118 + 'source-layer': 'landuse', 119 + 'filter': [ 120 + 'in', 121 + 'class', 122 + 'residential', 123 + 'commercial', 124 + 'retail', 125 + 'industrial', 126 + ], 127 + 'paint': { 128 + 'fill-color': 'hsl(47, 13%, 86%)', 129 + 'fill-opacity': 0.7, 130 + }, 131 + }, 132 + { 133 + 'id': 'landuse-civic', 134 + 'type': 'fill', 135 + 'source': 'world', 136 + 'source-layer': 'landuse', 137 + 'filter': ['in', 'class', 'school', 'university', 'hospital'], 138 + 'paint': { 139 + 'fill-color': 'hsl(47, 13%, 86%)', 140 + 'fill-opacity': 0.7, 141 + }, 142 + }, 143 + { 144 + 'id': 'landuse-military', 145 + 'type': 'fill', 146 + 'source': 'world', 147 + 'source-layer': 'landuse', 148 + 'filter': ['==', 'class', 'military'], 149 + 'paint': { 150 + 'fill-color': 'hsl(47, 13%, 86%)', 151 + 'fill-opacity': 0.7, 152 + }, 153 + }, 154 + 155 + // ── Parks / nature reserves ─────────────────────────────────────────────── 156 + { 157 + 'id': 'park', 158 + 'type': 'fill', 159 + 'source': 'world', 160 + 'source-layer': 'park', 161 + 'paint': { 162 + 'fill-color': '#E1EBB0', 163 + 'fill-opacity': { 'base': 1, 'stops': [[3, 0], [7, 0.75]] }, 164 + }, 165 + }, 166 + 167 + // ── Waterways ───────────────────────────────────────────────────────────── 168 + { 169 + 'id': 'waterway', 170 + 'type': 'line', 171 + 'source': 'world', 172 + 'source-layer': 'waterway', 173 + 'minzoom': 6, 174 + 'paint': { 175 + 'line-color': 'hsl(205, 56%, 73%)', 176 + 'line-opacity': 1, 177 + 'line-width': { 'base': 1.4, 'stops': [[6, 0.5], [9, 3]] }, 178 + }, 179 + }, 180 + 181 + // ── Boundaries ──────────────────────────────────────────────────────────── 182 + { 183 + 'id': 'boundary-state', 184 + 'type': 'line', 185 + 'source': 'world', 186 + 'source-layer': 'boundary', 187 + 'filter': ['all', ['>=', 'admin_level', 3], ['<=', 'admin_level', 6]], 188 + 'minzoom': 3, 189 + 'paint': { 190 + 'line-color': 'hsla(0, 0%, 60%, 0.5)', 191 + 'line-width': 0.5, 192 + 'line-dasharray': [2, 1], 193 + }, 194 + }, 195 + { 196 + 'id': 'boundary-country', 197 + 'type': 'line', 198 + 'source': 'world', 199 + 'source-layer': 'boundary', 200 + 'filter': ['==', 'admin_level', 2], 201 + 'paint': { 202 + 'line-color': 'hsl(0, 0%, 60%)', 203 + 'line-width': { 'base': 1.3, 'stops': [[0, 0.5], [4, 1], [7, 1.5]] }, 204 + }, 205 + }, 206 + 207 + // ── Place labels ────────────────────────────────────────────────────────── 208 + { 209 + 'id': 'place-continent', 210 + 'type': 'symbol', 211 + 'source': 'world', 212 + 'source-layer': 'place', 213 + 'filter': ['==', 'class', 'continent'], 214 + 'maxzoom': 3, 215 + 'layout': { 216 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 217 + 'text-font': ['Noto Sans Bold'], 218 + 'text-size': 13, 219 + 'text-transform': 'uppercase', 220 + 'text-letter-spacing': 0.1, 221 + }, 222 + 'paint': { 223 + 'text-color': 'hsl(0, 0%, 30%)', 224 + 'text-halo-color': 'rgba(255, 255, 255, 0.7)', 225 + 'text-halo-width': 1, 226 + }, 227 + }, 228 + { 229 + 'id': 'place-country', 230 + 'type': 'symbol', 231 + 'source': 'world', 232 + 'source-layer': 'place', 233 + 'filter': ['==', 'class', 'country'], 234 + 'maxzoom': 7, 235 + 'layout': { 236 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 237 + 'text-font': [ 238 + 'case', 239 + ['has', 'iso_a2'], 240 + ['literal', ['Noto Sans Bold']], 241 + ['literal', ['Noto Sans Regular']], 242 + ], 243 + 'text-max-width': 10, 244 + 'text-size': { 'stops': [[3, 12], [8, 22]] }, 245 + }, 246 + 'paint': { 247 + 'text-color': 'hsl(0, 0%, 13%)', 248 + 'text-halo-blur': 0, 249 + 'text-halo-color': 'rgba(255, 255, 255, 0.75)', 250 + 'text-halo-width': 2, 251 + }, 252 + }, 253 + { 254 + 'id': 'place-state', 255 + 'type': 'symbol', 256 + 'source': 'world', 257 + 'source-layer': 'place', 258 + 'filter': ['in', 'class', 'state', 'province'], 32 259 'minzoom': 4, 260 + 'maxzoom': 8, 261 + 'layout': { 262 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 263 + 'text-font': ['Noto Sans Regular'], 264 + 'text-size': 10, 265 + 'text-transform': 'uppercase', 266 + 'text-letter-spacing': 0.05, 267 + 'text-max-width': 6, 268 + }, 269 + 'paint': { 270 + 'text-color': 'hsl(0, 0%, 35%)', 271 + 'text-halo-color': 'rgba(255, 255, 255, 0.6)', 272 + 'text-halo-width': 1, 273 + 'text-opacity': 0.8, 274 + }, 275 + }, 276 + { 277 + 'id': 'place-city', 278 + 'type': 'symbol', 279 + 'source': 'world', 280 + 'source-layer': 'place', 281 + 'filter': ['==', 'class', 'city'], 282 + 'minzoom': 3, 283 + // Regional tiles take over place labels from z0, so no maxzoom needed — 284 + // city dots from world and regional will match since both use OSM place data. 285 + 'layout': { 286 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 287 + 'text-font': ['Noto Sans Regular'], 288 + 'text-max-width': 10, 289 + 'text-size': { 'stops': [[3, 12], [8, 16]] }, 290 + }, 291 + 'paint': { 292 + 'text-color': 'hsl(0, 0%, 0%)', 293 + 'text-halo-blur': 0, 294 + 'text-halo-color': 'rgba(255, 255, 255, 0.75)', 295 + 'text-halo-width': 2, 296 + }, 297 + }, 298 + { 299 + 'id': 'place-town', 300 + 'type': 'symbol', 301 + 'source': 'world', 302 + 'source-layer': 'place', 303 + 'filter': ['==', 'class', 'town'], 304 + 'minzoom': 6, 305 + 'layout': { 306 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 307 + 'text-font': ['Noto Sans Regular'], 308 + 'text-size': { 'stops': [[6, 10], [9, 14]] }, 309 + 'text-max-width': 6, 310 + }, 311 + 'paint': { 312 + 'text-color': 'hsl(0, 0%, 25%)', 313 + 'text-halo-blur': 0, 314 + 'text-halo-color': 'rgba(255, 255, 255, 0.75)', 315 + 'text-halo-width': 2, 316 + }, 317 + }, 318 + { 319 + 'id': 'place-village', 320 + 'type': 'symbol', 321 + 'source': 'world', 322 + 'source-layer': 'place', 323 + 'filter': ['==', 'class', 'village'], 324 + 'minzoom': 8, 325 + 'layout': { 326 + 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 327 + 'text-font': ['Noto Sans Regular'], 328 + 'text-size': { 'stops': [[6, 10], [12, 14]] }, 329 + 'text-max-width': 6, 330 + }, 331 + 'paint': { 332 + 'text-color': 'hsl(0, 0%, 25%)', 333 + 'text-halo-blur': 0, 334 + 'text-halo-color': 'rgba(255, 255, 255, 0.75)', 335 + 'text-halo-width': 2, 336 + }, 33 337 }, 34 338 ]