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: cleanup layers

- Adds docs/layers.md as a formal description of tile strategy
- Cleans up legacy tile terminology, mismatches between tiles and styles
- Roads:
- Tertiary: opacity 0 at z≤12, fades to 1 at z13
- Minor/service: opacity 0 at z≤13, fades to 1 at z14
- Motorway/trunk/primary/secondary: always opacity 1
- Buildings: unify to include all at z12
- Regional: Removing mountain_peak, water_name, water_name_detail
- landcoverKeys: drops wetland, bare_rock, scree
- landuseKeys: Trim to only residential
- trims unused placenames in regional tiles (provided by world)
- Fixes Water intermittent attribute bug, where intermittnet lakes never
fade

+408 -593
+133 -57
data/cli/commands/build.ts
··· 81 81 ) 82 82 } 83 83 84 + const PBF_SUFFIX = '-latest.osm.pbf' 85 + // Planet is handled by `build:world` and uses a different config/lua pair. 86 + const EXCLUDED_LEAVES = new Set(['planet']) 87 + 88 + // Expand a region (or the whole osm tree) into concrete leaf regions to 89 + // build by walking `data/osm/`. A "leaf" is a `<name>-latest.osm.pbf` file 90 + // with no sibling directory of the same name — so if `data/osm/asia/japan/` 91 + // exists, passing `asia/japan` builds every prefecture pbf inside that 92 + // directory rather than looking for `asia/japan-latest.osm.pbf`. 93 + async function collectLeafRegions(region?: string): Promise<string[]> { 94 + const parts = region ? region.split('/') : [] 95 + const dirPath = join(PBF_DIR, ...parts) 96 + const dirStat = await Deno.stat(dirPath).catch(() => null) 97 + 98 + if (dirStat?.isDirectory) { 99 + const leaves: string[] = [] 100 + for await (const entry of Deno.readDir(dirPath)) { 101 + if (entry.name.startsWith('.')) continue 102 + if (entry.isDirectory) { 103 + const child = [...parts, entry.name].join('/') 104 + leaves.push(...(await collectLeafRegions(child))) 105 + } else if (entry.isFile && entry.name.endsWith(PBF_SUFFIX)) { 106 + const base = entry.name.slice(0, -PBF_SUFFIX.length) 107 + if (EXCLUDED_LEAVES.has(base)) continue 108 + const sibling = await Deno.stat(join(dirPath, base)).catch(() => null) 109 + if (sibling?.isDirectory) continue 110 + leaves.push([...parts, base].join('/')) 111 + } 112 + } 113 + return leaves 114 + } 115 + 116 + if (region) { 117 + const parent = join(PBF_DIR, ...parts.slice(0, -1)) 118 + const fileName = `${leafName(region)}${PBF_SUFFIX}` 119 + const fileStat = await Deno.stat(join(parent, fileName)).catch(() => null) 120 + if (fileStat?.isFile) return [region] 121 + } 122 + return [] 123 + } 124 + 125 + async function buildRegion(region: string): Promise<void> { 126 + const parts = region.split('/') 127 + const id = leafName(region) 128 + const pbfPath = join( 129 + PBF_DIR, 130 + ...parts.slice(0, -1), 131 + `${id}-latest.osm.pbf`, 132 + ) 133 + const subDir = join(TILES_OUT_DIR, ...parts.slice(0, -1)) 134 + const outPath = join(subDir, `${id}.pmtiles`) 135 + 136 + if (!(await Deno.stat(pbfPath).catch(() => null))) { 137 + throw new Error( 138 + `PBF not found: ${pbfPath}\nRun: deno task data download:osm ${region}`, 139 + ) 140 + } 141 + 142 + await ensureDir(subDir) 143 + 144 + // Resolve bounding box for tilemaker so it can read regional shapefiles 145 + // (ocean, landcover, etc.) without scanning the whole world. 146 + // Strategy: try PBF header first (fast), fall back to poly file (reliable 147 + // for osmium-extracted files which often have no bbox in their header). 148 + const bboxArgs: string[] = [] 149 + const pbfBbox = await bboxFromPbfHeader(pbfPath) 150 + const polyBbox = await bboxFromPoly(region) 151 + const bbox = pbfBbox ?? (polyBbox ? polyBbox.join(',') : null) 152 + if (bbox) bboxArgs.push('--bbox', bbox) 153 + 154 + console.log(`Building tiles for ${region} ...`) 155 + 156 + await run('tilemaker', [ 157 + '--input', 158 + pbfPath, 159 + '--output', 160 + outPath, 161 + '--config', 162 + CONFIG_JSON, 163 + '--process', 164 + PROCESS_LUA, 165 + ...bboxArgs, 166 + ]) 167 + 168 + console.log(`Saved: ${outPath}`) 169 + 170 + const { size } = await Deno.stat(outPath) 171 + const sizeStr = size < 1024 * 1024 172 + ? `${Math.round(size / 1024)} KB` 173 + : `${Math.round(size / (1024 * 1024))} MB` 174 + const label = id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)) 175 + .join(' ') 176 + const relativePath = [...parts.slice(0, -1), `${id}.pmtiles`].join('/') 177 + await upsertManifest({ 178 + id, 179 + label, 180 + description: `Detailed street map · ~${sizeStr}`, 181 + filename: relativePath, 182 + path: `/static/tiles/${relativePath}`, 183 + group: parts.slice(0, -1).join('/'), 184 + bounds: polyBbox ?? undefined, 185 + }) 186 + console.log(`Updated tiles manifest`) 187 + } 188 + 84 189 export const buildCmd = new Command() 85 190 .name('build') 86 191 .description( 87 - 'Run tilemaker to generate a .pmtiles file from a regional .osm.pbf.', 192 + 'Run tilemaker to generate .pmtiles files from regional .osm.pbf ' + 193 + 'extracts. Omit the region to build every leaf region, or pass a ' + 194 + 'parent (e.g. asia/japan) to build all of its children.', 88 195 ) 89 - .arguments('<region:string>') 196 + .arguments('[region:string]') 90 197 .action(async (_, region) => { 91 - const parts = region.split('/') 92 - const id = leafName(region) 93 - const pbfPath = join( 94 - PBF_DIR, 95 - ...parts.slice(0, -1), 96 - `${id}-latest.osm.pbf`, 97 - ) 98 - const subDir = join(TILES_OUT_DIR, ...parts.slice(0, -1)) 99 - const outPath = join(subDir, `${id}.pmtiles`) 100 - 101 - if (!(await Deno.stat(pbfPath).catch(() => null))) { 198 + const regions = (await collectLeafRegions(region)).sort() 199 + if (!regions.length) { 102 200 throw new Error( 103 - `PBF not found: ${pbfPath}\nRun: deno task data download:osm ${region}`, 201 + region 202 + ? `No .osm.pbf files found for ${region}\nRun: deno task data download:osm ${region}` 203 + : `No .osm.pbf files found under ${PBF_DIR}`, 104 204 ) 105 205 } 106 206 107 - await ensureDir(subDir) 207 + console.log( 208 + `Building ${regions.length} region(s):\n ${regions.join('\n ')}`, 209 + ) 108 210 109 - // Resolve bounding box for tilemaker so it can read regional shapefiles 110 - // (ocean, landcover, etc.) without scanning the whole world. 111 - // Strategy: try PBF header first (fast), fall back to poly file (reliable 112 - // for osmium-extracted files which often have no bbox in their header). 113 - const bboxArgs: string[] = [] 114 - const pbfBbox = await bboxFromPbfHeader(pbfPath) 115 - const polyBbox = await bboxFromPoly(region) 116 - const bbox = pbfBbox ?? (polyBbox ? polyBbox.join(',') : null) 117 - if (bbox) bboxArgs.push('--bbox', bbox) 211 + const failures: string[] = [] 212 + for (const r of regions) { 213 + try { 214 + await buildRegion(r) 215 + } catch (error) { 216 + console.error(`Failed to build ${r}: ${(error as Error).message}`) 217 + failures.push(r) 218 + } 219 + } 118 220 119 - console.log(`Building tiles for ${region} ...`) 120 - 121 - await run('tilemaker', [ 122 - '--input', 123 - pbfPath, 124 - '--output', 125 - outPath, 126 - '--config', 127 - CONFIG_JSON, 128 - '--process', 129 - PROCESS_LUA, 130 - ...bboxArgs, 131 - ]) 132 - 133 - console.log(`Saved: ${outPath}`) 134 - 135 - const { size } = await Deno.stat(outPath) 136 - const sizeStr = size < 1024 * 1024 137 - ? `${Math.round(size / 1024)} KB` 138 - : `${Math.round(size / (1024 * 1024))} MB` 139 - const label = id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)) 140 - .join(' ') 141 - const relativePath = [...parts.slice(0, -1), `${id}.pmtiles`].join('/') 142 - await upsertManifest({ 143 - id, 144 - label, 145 - description: `Detailed street map · ~${sizeStr}`, 146 - filename: relativePath, 147 - path: `/static/tiles/${relativePath}`, 148 - group: parts.slice(0, -1).join('/'), 149 - bounds: polyBbox ?? undefined, 150 - }) 151 - console.log(`Updated tiles manifest`) 221 + if (failures.length) { 222 + throw new Error( 223 + `Failed to build ${failures.length} of ${regions.length} region(s): ${ 224 + failures.join(', ') 225 + }`, 226 + ) 227 + } 152 228 })
+1 -9
data/cli/shared/tilemaker/config.json
··· 46 46 "maxzoom": 14, 47 47 "write_to": "water" 48 48 }, 49 - "water_name": { "minzoom": 14, "maxzoom": 14 }, 50 - "water_name_detail": { 51 - "minzoom": 14, 52 - "maxzoom": 14, 53 - "write_to": "water_name" 54 - }, 55 - 56 49 "aeroway": { "minzoom": 11, "maxzoom": 14 }, 57 50 "aerodrome_label": { "minzoom": 10, "maxzoom": 14 }, 58 51 "park": { "minzoom": 11, "maxzoom": 14 }, ··· 69 62 "simplify_below": 13, 70 63 "simplify_level": 0.0003, 71 64 "simplify_ratio": 2 72 - }, 73 - "mountain_peak": { "minzoom": 11, "maxzoom": 14 } 65 + } 74 66 }, 75 67 "settings": { 76 68 "minzoom": 0,
+1 -18
data/cli/shared/tilemaker/config.world.json
··· 18 18 "simplify_ratio": 2 19 19 }, 20 20 21 - "transportation": { 22 - "minzoom": 4, 23 - "maxzoom": 9, 24 - "simplify_below": 9, 25 - "simplify_level": 0.0003 26 - }, 27 - "transportation_name": { "minzoom": 8, "maxzoom": 9 }, 28 - 29 21 "water": { 30 22 "minzoom": 4, 31 23 "maxzoom": 9, ··· 47 39 }, 48 40 49 41 "park": { "minzoom": 7, "maxzoom": 9 }, 50 - "landuse": { 51 - "minzoom": 6, 52 - "maxzoom": 9, 53 - "simplify_below": 9, 54 - "simplify_level": 0.0003, 55 - "simplify_ratio": 2 56 - }, 57 42 "landcover": { 58 43 "minzoom": 0, 59 44 "maxzoom": 9, 60 45 "simplify_below": 9, 61 46 "simplify_level": 0.0003, 62 47 "simplify_ratio": 2 63 - }, 64 - 65 - "mountain_peak": { "minzoom": 7, "maxzoom": 9 } 48 + } 66 49 }, 67 50 "settings": { 68 51 "minzoom": 0,
+14 -100
data/cli/shared/tilemaker/process.lua
··· 152 152 local capital = capitalLevel(Find("capital")) 153 153 local rank = calcRank(place, pop, capital) 154 154 155 - if place == "continent" then mz=0 156 - elseif place == "country" then 155 + if place == "country" then 157 156 if pop>50000000 then rank=1; mz=1 158 157 elseif pop>20000000 then rank=2; mz=2 159 158 else rank=3; mz=3 end 160 - elseif place == "state" then mz=4 161 - elseif place == "province" then mz=5 162 159 elseif place == "city" then mz=5 163 160 elseif place == "town" and pop>8000 then mz=7 164 161 elseif place == "town" then mz=8 165 - elseif place == "village" and pop>2000 then mz=9 166 - elseif place == "village" then mz=10 167 - elseif place == "borough" then mz=10 168 162 elseif place == "suburb" then mz=11 169 163 elseif place == "quarter" then mz=12 170 - elseif place == "hamlet" then mz=12 171 164 elseif place == "neighbourhood" then mz=13 172 - elseif place == "isolated_dwelling" then mz=13 173 - elseif place == "locality" then mz=13 174 - elseif place == "island" then mz=12 175 - end 165 + else return end 176 166 177 167 Layer("place", false) 178 168 Attribute("class", place) ··· 197 187 -- Write 'poi' 198 188 local rank, class, subclass = GetPOIRank() 199 189 if rank then WritePOI(class,subclass,rank) end 200 - 201 - -- Write 'mountain_peak' and 'water_name' 202 - local natural = Find("natural") 203 - if natural == "peak" or natural == "volcano" then 204 - Layer("mountain_peak", false) 205 - SetEleAttributes() 206 - AttributeNumeric("rank", 1) 207 - Attribute("class", natural) 208 - SetNameAttributes() 209 - return 210 - end 211 - if natural == "bay" then 212 - Layer("water_name", false) 213 - SetNameAttributes() 214 - return 215 - end 216 190 end 217 191 218 192 -- Process way tags ··· 233 207 railwayClasses = { rail="rail", narrow_gauge="rail", preserved="rail", funicular="rail", subway="transit", light_rail="transit", monorail="transit", tram="transit" } 234 208 235 209 aerowayBuildings= Set { "terminal", "gate", "tower" } 236 - landuseKeys = Set { "school", "university", "kindergarten", "college", "library", "hospital", 237 - "railway", "cemetery", "military", "residential", "commercial", "industrial", 238 - "retail", "stadium", "pitch", "playground", "theme_park", "bus_station", "zoo" } 210 + landuseKeys = Set { "residential" } 239 211 landcoverKeys = { wood="wood", forest="wood", 240 - wetland="wetland", 241 212 beach="sand", sand="sand", dune="sand", 242 213 farmland="farmland", farm="farmland", orchard="farmland", vineyard="farmland", plant_nursery="farmland", 243 214 glacier="ice", ice_shelf="ice", 244 - bare_rock="rock", scree="rock", 245 - fell="grass", grassland="grass", grass="grass", heath="grass", meadow="grass", allotments="grass", park="grass", village_green="grass", recreation_ground="grass", scrub="grass", shrubbery="grass", tundra="grass", garden="grass", golf_course="grass", park="grass" } 215 + fell="grass", grassland="grass", grass="grass", heath="grass", meadow="grass", allotments="grass", park="grass", village_green="grass", recreation_ground="grass", scrub="grass", shrubbery="grass", tundra="grass", garden="grass", golf_course="grass" } 246 216 247 217 -- POI key/value pairs: based on https://github.com/openmaptiles/openmaptiles/blob/master/layers/poi/mapping.yaml 248 218 poiTags = { aerialway = Set { "station" }, ··· 372 342 if landuse == "field" then landuse = "farmland" end 373 343 if landuse == "meadow" and Find("meadow")=="agricultural" then landuse="farmland" end 374 344 375 - if place == "island" then 376 - LayerAsCentroid("place") 377 - Attribute("class", place) 378 - MinZoom(10) 379 - local pop = tonumber(Find("population")) or 0 380 - local capital = capitalLevel(Find("capital")) 381 - local rank = calcRank(place, pop, nil) 382 - if rank then AttributeNumeric("rank", rank) end 383 - SetNameAttributes() 384 - end 385 - 386 345 -- Boundaries within relations 387 346 -- note that we process administrative boundaries as properties on ways, rather than as single relation geometries, 388 347 -- because otherwise we get multiple renderings where boundaries are coterminous ··· 414 373 Layer("boundary",false) 415 374 AttributeNumeric("admin_level", admin_level) 416 375 MinZoom(mz) 417 - -- disputed status (0 or 1). some styles need to have the 0 to show it. 418 - local disputed = Find("disputed") 419 - if disputed=="yes" then 420 - AttributeNumeric("disputed", 1) 421 - else 422 - AttributeNumeric("disputed", 0) 423 - end 424 376 end 425 377 426 378 -- Aerialways ('transportation' and 'transportation_name') ··· 608 560 Attribute("class", waterway) 609 561 SetNameAttributes() 610 562 SetBrunnelAttributes() 611 - elseif waterway == "boatyard" then Layer("landuse", is_closed); Attribute("class", "industrial"); MinZoom(12) 612 563 elseif waterway == "dam" then Layer("building",is_closed) 613 - elseif waterway == "fuel" then Layer("landuse", is_closed); Attribute("class", "industrial"); MinZoom(14) 614 - end 615 - -- Set names on rivers 616 - if waterwayClasses[waterway] and not is_closed then 617 - if waterway == "river" and Holds("name") then 618 - Layer("water_name", false) 619 - else 620 - Layer("water_name_detail", false) 621 - MinZoom(14) 622 - end 623 - Attribute("class", waterway) 624 - SetNameAttributes() 625 564 end 626 565 627 566 -- Set 'building' and associated ··· 646 585 SetMinZoomByArea(way) 647 586 Attribute("class",class) 648 587 649 - if Find("intermittent")=="yes" then Attribute("intermittent",1) end 650 - -- we only want to show the names of actual lakes not every man-made basin that probably doesn't even have a name other than "basin" 651 - -- examples for which we don't want to show a name: 652 - -- https://www.openstreetmap.org/way/25958687 653 - -- https://www.openstreetmap.org/way/27201902 654 - -- https://www.openstreetmap.org/way/25309134 655 - -- https://www.openstreetmap.org/way/24579306 656 - if Holds("name") and natural=="water" and water ~= "basin" and water ~= "wastewater" then 657 - LayerAsCentroid("water_name_detail") 658 - SetNameAttributes() 659 - SetMinZoomByArea() 660 - Attribute("class", class) 661 - end 588 + if Find("intermittent")=="yes" then AttributeNumeric("intermittent",1) end 662 589 663 590 return -- in case we get any landuse processing 664 591 end ··· 671 598 Layer("landcover", true) 672 599 SetMinZoomByArea() 673 600 Attribute("class", landcoverKeys[l]) 674 - if l=="wetland" then Attribute("subclass", Find("wetland")) 675 - else Attribute("subclass", l) end 601 + Attribute("subclass", l) 676 602 write_name = true 677 - 678 - -- Set 'landuse' 679 - else 680 - if l=="" then l=amenity end 681 - if l=="" then l=tourism end 682 - if landuseKeys[l] then 683 - Layer("landuse", true) 684 - Attribute("class", l) 685 - if l=="residential" then 686 - if Area()<ZRES8^2 then MinZoom(8) 687 - else SetMinZoomByArea() end 688 - else MinZoom(11) end 689 - write_name = true 690 - end 603 + elseif l=="residential" then 604 + Layer("landuse", true) 605 + Attribute("class", l) 606 + if Area()<ZRES8^2 then MinZoom(8) 607 + else SetMinZoomByArea() end 608 + write_name = true 691 609 end 692 610 693 611 -- Parks ··· 789 707 SetMinZoomByAreaWithLimit(0) 790 708 end 791 709 792 - -- Buildings: pop in together around z12–z13 instead of dribbling 793 - -- in tier-by-tier through z14. Big footprints get z12, everything 794 - -- else gets z13 so the style's z12→z13 fade covers both. 710 + -- Buildings: all pop in together at z12 with a style-driven opacity fade. 795 711 function SetBuildingMinZoomByArea() 796 - local area=Area() 797 - if area>ZRES12^2 then MinZoom(12) 798 - else MinZoom(13) end 712 + MinZoom(12) 799 713 end 800 714 801 715 -- Set minimum zoom level by area but not below given minzoom
+5 -188
data/cli/shared/tilemaker/process.world.lua
··· 18 18 return set 19 19 end 20 20 21 - ZRES5 = 4891.97 22 - ZRES6 = 2445.98 23 - ZRES7 = 1222.99 24 - ZRES8 = 611.5 25 - ZRES9 = 305.7 26 - ZRES10 = 152.9 27 - ZRES11 = 76.4 28 - ZRES12 = 38.2 29 - ZRES13 = 19.1 21 + ZRES5 = 4891.97 22 + ZRES6 = 2445.98 23 + ZRES7 = 1222.99 24 + ZRES8 = 611.5 30 25 31 - INVALID_ZOOM = 99 32 - 33 - aerodromeValues = Set { "international", "public", "regional", "military", "private" } 34 - pavedValues = Set { "paved", "asphalt", "cobblestone", "concrete", "concrete:lanes", "concrete:plates", "metal", "paving_stones", "sett", "unhewn_cobblestone", "wood" } 35 - unpavedValues = Set { "unpaved", "compacted", "dirt", "earth", "fine_gravel", "grass", "grass_paver", "gravel", "gravel_turf", "ground", "ice", "mud", "pebblestone", "salt", "sand", "snow", "woodchips" } 36 - 37 - node_keys = { "aeroway","natural","place","railway","waterway" } 26 + node_keys = { "natural","place","waterway" } 38 27 39 28 function capitalLevel(capital) 40 29 local capital_al = tonumber(capital) or 0 ··· 134 123 SetNameAttributes() 135 124 return 136 125 end 137 - 138 - -- Write 'mountain_peak' 139 - local natural = Find("natural") 140 - if natural == "peak" or natural == "volcano" then 141 - Layer("mountain_peak", false) 142 - SetEleAttributes() 143 - AttributeNumeric("rank", 1) 144 - Attribute("class", natural) 145 - SetNameAttributes() 146 - end 147 126 end 148 127 149 - majorRoadValues = Set { "motorway", "trunk", "primary" } 150 - z9RoadValues = Set { "secondary", "motorway_link", "trunk_link" } 151 - linkValues = Set { "motorway_link", "trunk_link", "primary_link", "secondary_link", "tertiary_link" } 152 - railwayClasses = { rail="rail", narrow_gauge="rail", preserved="rail", funicular="rail", subway="transit", light_rail="transit", monorail="transit", tram="transit" } 153 - 154 128 landcoverKeys = { wood="wood", forest="wood", 155 129 wetland="wetland", 156 130 beach="sand", sand="sand", dune="sand", ··· 160 134 fell="grass", grassland="grass", grass="grass", heath="grass", meadow="grass", 161 135 allotments="grass", park="grass", village_green="grass", recreation_ground="grass", 162 136 scrub="grass", shrubbery="grass", tundra="grass", garden="grass" } 163 - 164 - landuseKeys = Set { "school", "university", "hospital", "railway", "cemetery", "military", 165 - "residential", "commercial", "industrial", "retail", "stadium", "pitch" } 166 137 167 138 waterwayClasses = Set { "stream", "river", "canal", "drain", "ditch" } 168 139 waterClasses = Set { "river", "riverbank", "stream", "canal", "drain", "ditch", "dock" } ··· 173 144 end 174 145 end 175 146 176 - function write_to_transportation_layer(minzoom, highway_class, subclass, ramp, service, is_rail, is_road, is_area) 177 - Layer("transportation", is_area) 178 - SetZOrder() 179 - Attribute("class", highway_class) 180 - if subclass and subclass ~= "" then 181 - Attribute("subclass", subclass) 182 - end 183 - AttributeNumeric("layer", tonumber(Find("layer")) or 0) 184 - SetBrunnelAttributes() 185 - if is_area then 186 - SetMinZoomByAreaWithLimit(minzoom) 187 - return 188 - end 189 - MinZoom(minzoom) 190 - if ramp then AttributeNumeric("ramp",1) end 191 - if (is_rail or highway_class == "service") and (service and service ~="") then Attribute("service", service) end 192 - end 193 - 194 147 function way_function() 195 - local route = Find("route") 196 - local highway = Find("highway") 197 148 local waterway = Find("waterway") 198 - local water = Find("water") 199 149 local natural = Find("natural") 200 150 local landuse = Find("landuse") 201 151 local leisure = Find("leisure") 202 - local amenity = Find("amenity") 203 - local railway = Find("railway") 204 - local service = Find("service") 205 152 local boundary = Find("boundary") 206 - local place = Find("place") 207 153 local is_closed = IsClosed() 208 - local construction = Find("construction") 209 - local is_highway_area = highway~="" and Find("area")=="yes" and is_closed 210 154 211 155 if Find("disused") == "yes" then return end 212 - if highway == "proposed" then return end 213 - 214 - if place == "island" then 215 - LayerAsCentroid("place") 216 - Attribute("class", place) 217 - MinZoom(10) 218 - SetNameAttributes() 219 - end 220 156 221 157 -- Administrative boundaries 222 158 local admin_level = 11 ··· 241 177 Layer("boundary",false) 242 178 AttributeNumeric("admin_level", admin_level) 243 179 MinZoom(mz) 244 - local disputed = Find("disputed") 245 - if disputed=="yes" then AttributeNumeric("disputed", 1) 246 - else AttributeNumeric("disputed", 0) end 247 - end 248 - 249 - -- Roads 250 - if highway ~= "" then 251 - local h = highway 252 - if highway == "construction" and Find("construction") ~= "" then 253 - h = Find("construction") 254 - end 255 - local minzoom = INVALID_ZOOM 256 - if majorRoadValues[h] then minzoom = 4 257 - elseif h == "trunk" then minzoom = 5 258 - elseif highway == "primary" then minzoom = 7 259 - elseif z9RoadValues[h] then minzoom = 9 260 - end 261 - -- Only major roads are relevant at world zoom; skip everything else 262 - local ramp = false 263 - if linkValues[h] then 264 - h = h:match("^(%a+)_link") or h 265 - ramp = true 266 - end 267 - if minzoom <= 9 and not is_highway_area then 268 - write_to_transportation_layer(minzoom, h, nil, ramp, service, false, true, false) 269 - if not is_closed and (HasNames() or Holds("ref")) then 270 - local namezoom = minzoom 271 - if h == "motorway" then namezoom = 7 272 - elseif h == "trunk" then namezoom = 8 273 - elseif h == "primary" then namezoom = 9 274 - end 275 - Layer("transportation_name", false) 276 - MinZoom(namezoom) 277 - SetNameAttributes() 278 - Attribute("class", h) 279 - Attribute("network", "road") 280 - local ref = Find("ref") 281 - if ref~="" then 282 - Attribute("ref", ref) 283 - AttributeNumeric("ref_length", ref:len()) 284 - end 285 - end 286 - end 287 - end 288 - 289 - -- Railways (main lines only) 290 - if railway ~= "" then 291 - local class = railwayClasses[railway] 292 - if class and class == "rail" then 293 - local usage = Find("usage") 294 - local minzoom = 14 295 - if usage == "main" then minzoom = 8 end 296 - if minzoom <= 9 then 297 - write_to_transportation_layer(minzoom, class, railway, false, service, true, false, is_closed) 298 - end 299 - end 300 - end 301 - 302 - -- Ferry 303 - if route=="ferry" then 304 - write_to_transportation_layer(9, "ferry", nil, false, nil, false, false, is_closed) 305 180 end 306 181 307 182 -- Waterways ··· 333 208 SetMinZoomByArea() 334 209 Attribute("class", landcoverKeys[l]) 335 210 Attribute("subclass", l) 336 - elseif landuseKeys[l] then 337 - Layer("landuse", true) 338 - Attribute("class", l) 339 - SetMinZoomByArea() 340 211 end 341 212 342 213 -- Parks / nature reserves ··· 356 227 -- ========================================================== 357 228 -- Common functions 358 229 359 - function HasNames() 360 - if Holds("name") then return true end 361 - if preferred_language and Holds("name:"..preferred_language) then return true end 362 - for i,lang in ipairs(additional_languages) do 363 - if Holds("name:"..lang) then return true end 364 - end 365 - return false 366 - end 367 - 368 230 function SetNameAttributes() 369 231 local name = Find("name") 370 232 local main_written = name ··· 384 246 end 385 247 end 386 248 387 - function SetEleAttributes() 388 - local ele = Find("ele") 389 - if ele ~= "" then 390 - local meter = math.floor(tonumber(ele) or 0) 391 - local feet = math.floor(meter * 3.2808399) 392 - AttributeNumeric("ele", meter) 393 - AttributeNumeric("ele_ft", feet) 394 - end 395 - end 396 - 397 249 function SetBrunnelAttributes() 398 250 if Find("bridge") == "yes" or Find("man_made") == "bridge" then Attribute("brunnel", "bridge") 399 251 elseif Find("tunnel") == "yes" then Attribute("brunnel", "tunnel") ··· 412 264 elseif minzoom <= 8 and area>ZRES7^2 then MinZoom(8) 413 265 elseif minzoom <= 9 and area>ZRES8^2 then MinZoom(9) 414 266 else MinZoom(9) end 415 - end 416 - 417 - function SetZOrder() 418 - local highway = Find("highway") 419 - local layer = tonumber(Find("layer")) 420 - local bridge = Find("bridge") 421 - local tunnel = Find("tunnel") 422 - local zOrder = 0 423 - if bridge ~= "" and bridge ~= "no" then 424 - zOrder = zOrder + 10 425 - elseif tunnel ~= "" and tunnel ~= "no" then 426 - zOrder = zOrder - 10 427 - end 428 - if not (layer == nil) then 429 - if layer > 7 then layer = 7 elseif layer < -7 then layer = -7 end 430 - zOrder = zOrder + layer * 10 431 - end 432 - local hwClass = 0 433 - if highway == "motorway" then hwClass = 9 434 - elseif highway == "trunk" then hwClass = 8 435 - elseif highway == "primary" then hwClass = 6 436 - elseif highway == "secondary" then hwClass = 5 437 - else hwClass = 3 438 - end 439 - zOrder = zOrder + hwClass 440 - ZOrder(zOrder) 441 - end 442 - 443 - function split(inputstr, sep) 444 - if sep == nil then sep = "%s" end 445 - local t={} ; i=1 446 - for str in string.gmatch(inputstr, "([^"..sep.."]+)") do 447 - t[i] = str ; i = i + 1 448 - end 449 - return t 450 267 end 451 268 452 269 -- vim: tabstop=2 shiftwidth=2 noexpandtab
+209 -88
docs/layers.md
··· 1 1 # Map Layers 2 2 3 - This document describes the different map layers used in the app and how they are ordered. 3 + End-to-end layer pipeline for the MapLibre-based map, from OSM tags through 4 + tilemaker to style layers. 5 + 6 + ## Pipeline 7 + 8 + Two tilemaker pipelines produce PMTiles, both emitting an 9 + OpenMapTiles-compatible schema: 10 + 11 + | Pipeline | Config | Processor | Zoom | Output | 12 + | --------------- | ----------------------------------------------- | ------------------ | ---- | ------------------------ | 13 + | World overview | `data/cli/shared/tilemaker/config.world.json` | `process.world.lua`| 0–9 | single global PMTiles | 14 + | Regional detail | `data/cli/shared/tilemaker/config.json` | `process.lua` | 0–14 | per-region PMTiles | 15 + 16 + Regional pipelines extract full OSM detail. The world pipeline is a 17 + deliberately stripped subset — no buildings, housenumbers, POIs, aeroways, or 18 + minor roads — because those are distracting at global zoom and only appear 19 + once a region is downloaded. 20 + 21 + MapLibre consumes both through `www/utils/layers.ts`: 22 + 23 + - The **default export** returns regional style layers for a given source name. 24 + - The **named `WORLD_LAYERS` export** returns style layers bound to the `world` 25 + source. 26 + 27 + Loading order (`www/components/m-map.ts`): 28 + 29 + 1. `#loadWorldTiles()` adds the `world` source + `WORLD_LAYERS` on init. 30 + 2. `#loadCachedDetailTiles()` adds each regional source + regional layers as 31 + regions are downloaded. 32 + 3. Bookmark layers are appended and `#ensureBookmarkLayersOnTop()` keeps them 33 + above everything else. 34 + 35 + ## Source-layer coverage 36 + 37 + Source-layers below are what tilemaker emits — MapLibre style layers reference 38 + them via `source-layer`. 39 + 40 + ### Regional (`config.json`) 41 + 42 + | Source-layer | Zoom | Notes | 43 + | --------------------- | ------- | ------------------------------------------------- | 44 + | `place` | 0–14 | country, city, town, suburb, quarter, neighbourhood | 45 + | `boundary` | 0–14 | admin_level 2–8 | 46 + | `water` | 6–14 | lakes, rivers, reservoirs | 47 + | `ocean` | 0–14 | written into `water` | 48 + | `waterway` | 8–14 | named rivers (+ `waterway_detail` z12–14) | 49 + | `landcover` | 0–14 | grass, wood, sand, ice, farmland | 50 + | `landuse` | 4–14 | residential only | 51 + | `park` | 11–14 | national_park, nature_reserve | 52 + | `transportation` | 4–14 | roads, rail, ferry, pier, aerialway | 53 + | `transportation_name` | 8–14 | road + rail names | 54 + | `aeroway` | 11–14 | runways, taxiways | 55 + | `aerodrome_label` | 10–14 | | 56 + | `building` | 12–14 | | 57 + | `housenumber` | 14 | | 58 + | `poi` / `poi_detail` | 12–14 | `poi_detail` (rank 5+) written into `poi` | 59 + 60 + ### World (`config.world.json`) 61 + 62 + | Source-layer | Zoom | Notes | 63 + | ------------ | ----- | ----------------------------------------- | 64 + | `place` | 0–9 | continent through village | 65 + | `boundary` | 0–9 | | 66 + | `water` | 4–9 | inland water | 67 + | `ocean` | 0–9 | loaded from coastline shapefile → `water` | 68 + | `waterway` | 8–9 | named rivers only | 69 + | `landcover` | 0–9 | grass, wood, wetland, sand, rock, ice | 70 + | `park` | 7–9 | | 71 + 72 + Intentionally omitted from world: transportation (all roads/rail/ferry), 73 + buildings, housenumbers, POIs, aeroways, landuse, mountain peaks. 74 + 75 + ## Style layers 76 + 77 + MapLibre evaluates every layer per frame during pan/zoom. With multiple 78 + regional tile sources loaded simultaneously, the total layer count is 79 + `(regional layers × regions) + world layers + bookmark layers`. Keeping the 80 + per-source layer count low directly improves frame time. 4 81 5 - ## Layer Stack (bottom to top) 82 + | Source | Layers | 83 + | --------------------- | ------ | 84 + | Regional (per source) | 34 | 85 + | World | 10 | 86 + | Bookmarks | 5 | 6 87 7 - ### 1. World Basemap Layers (`world_layers.ts`) 88 + With 3 regional tiles loaded: **117 layers total**. 8 89 9 - Low-resolution global basemap for zoom levels 0-5. Loaded from PMTiles. 90 + ### Regional layers (`layers.ts` default export) 10 91 11 - | Layer ID | Type | Source Layer | Description | 12 - | ------------------- | ------ | ------------ | ------------------------------------------- | 13 - | `water` | fill | water | Ocean and inland water polygons | 14 - | `landcover-grass` | fill | landcover | Grass areas | 15 - | `landcover-wood` | fill | landcover | Forest/woodland | 16 - | `landcover-wetland` | fill | landcover | Wetland | 17 - | `landcover-sand` | fill | landcover | Sand/desert | 18 - | `landcover-rock` | fill | landcover | Rocky areas | 19 - | `landcover-ice` | fill | landcover | Ice caps/glaciers | 20 - | `park` | fill | park | National parks/nature reserves | 21 - | `waterway` | line | waterway | Named rivers | 22 - | `boundary-state` | line | boundary | State/province boundaries (admin_level 3-6) | 23 - | `boundary-country` | line | boundary | Country boundaries (admin_level 2) | 24 - | `place-continent` | symbol | place | Continent labels | 25 - | `place-country` | symbol | place | Country labels | 26 - | `place-state` | symbol | place | State/province labels | 27 - | `place-city` | symbol | place | City labels | 28 - | `place-town` | symbol | place | Town labels | 29 - | `place-village` | symbol | place | Village labels | 92 + 34 layers, rendered bottom to top: 30 93 31 - ### 2. Regional/Detail Layers (`layers.ts`) 94 + | # | Layer ID | Type | Source-layer | Notes | 95 + | -- | ------------------------------- | ------ | ------------------- | --------------------------------------------------------------------------------------------------- | 96 + | 1 | `landuse-residential` | fill | landuse | z11+, residential / suburb / neighbourhood | 97 + | 2 | `landcover` | fill | landcover | **Merged** grass + wood + sand via `match` on class | 98 + | 3 | `water` | fill | water | **Merged** normal + intermittent via opacity expression | 99 + | 4 | `landcover-ice-shelf` | fill | landcover | Separate — renders above water | 100 + | 5 | `landcover-glacier` | fill | landcover | Separate — opacity ramp z0→z8 | 101 + | 6 | `landuse` | fill | landcover | Farmland fill | 102 + | 7 | `landuse_overlay_national_park` | fill | park | National parks + nature reserves, opacity ramp z11→z13 | 103 + | 8 | `waterway-tunnel` | line | waterway | Dashed + gap-width (unique paint) | 104 + | 9 | `waterway` | line | waterway | Normal waterways | 105 + | 10 | `waterway_intermittent` | line | waterway | Dashed (can't merge — `line-dasharray` not data-driven) | 106 + | 11 | `tunnel_railway_transit` | line | transportation | Kept separate — opacity ramp differs from road tunnels | 107 + | 12 | `building` | fill | building | z13+ fade-in | 108 + | 13 | `housenumber` | symbol | housenumber | z17+ | 109 + | 14 | `road_area_pier` | fill | transportation | Polygon piers | 110 + | 15 | `road_pier` | line | transportation | Line piers | 111 + | 16 | `road_bridge_area` | fill | transportation | Bridge polygons | 112 + | 17 | `road_path` | line | transportation | Kept separate — dashed, square cap | 113 + | 18 | `tunnel_road` | line | transportation | **Merged** minor + major tunnels | 114 + | 19 | `aeroway-area` | fill | aeroway | Runway / taxiway polygons | 115 + | 20 | `aeroway-taxiway` | line | aeroway | Kept separate — different minzoom / width from runway | 116 + | 21 | `aeroway-runway` | line | aeroway | z4+ | 117 + | 22 | `road` | line | transportation | **Merged** minor, service, secondary, tertiary, trunk, primary, motorway — class-based `line-width` | 118 + | 23 | `railway` | line | transportation | **Merged** rail + transit | 119 + | 24 | `waterway-bridge-case` | line | waterway | Different source-layer from road bridges | 120 + | 25 | `waterway-bridge` | line | waterway | | 121 + | 26 | `bridge_case` | line | transportation | **Merged** minor + major bridge casing | 122 + | 27 | `bridge` | line | transportation | **Merged** minor + major bridge fill | 123 + | 28 | `admin_sub` | line | boundary | State / province boundaries | 124 + | 29 | `admin_country` | line | boundary | z5+ only — world tiles cover z0–5 | 125 + | 30 | `poi_label` | symbol | poi | z15+, rank 1 only | 126 + | 31 | `airport-label` | symbol | aerodrome_label | z11+, IATA airports | 127 + | 32 | `road_major_label` | symbol | transportation_name | z14+, line placement | 128 + | 33 | `place_label` | symbol | place | **Merged** city + town + suburb / neighbourhood / quarter — single collision pass | 129 + | 34 | `country_label` | symbol | place | Country names, z7–11 (world covers z0–6) | 32 130 33 - Higher resolution tiles loaded on demand (PMTiles). These are prefixed with the source ID (e.g., `europe-water`, `us-landuse`). 131 + ### World layers (`WORLD_LAYERS` export) 132 + 133 + 10 layers, rendered bottom to top: 34 134 35 - | Layer ID | Type | Source Layer | Description | 36 - | --------------------------------- | ------ | ------------------- | -------------------------- | 37 - | `*-landuse-residential` | fill | landuse | Residential areas | 38 - | `*-landcover_grass` | fill | landcover | Grass | 39 - | `*-landcover_wood` | fill | landcover | Forest | 40 - | `*-water` | fill | water | Water polygons | 41 - | `*-water_intermittent` | fill | water | Intermittent water | 42 - | `*-landcover-ice-shelf` | fill | landcover | Ice shelves | 43 - | `*-landcover-glacier` | fill | landcover | Glaciers | 44 - | `*-landcover_sand` | fill | landcover | Sand | 45 - | `*-landuse` | fill | landuse | Agriculture | 46 - | `*-landuse_overlay_national_park` | fill | landcover | National parks | 47 - | `*-waterway-tunnel` | line | waterway | Tunnels | 48 - | `*-waterway` | line | waterway | Rivers | 49 - | `*-waterway_intermittent` | line | waterway | Intermittent rivers | 50 - | `*-tunnel_railway_transit` | line | transportation | Railway tunnels | 51 - | `*-building` | fill | building | Buildings | 52 - | `*-housenumber` | symbol | housenumber | House numbers | 53 - | `*-road_area_pier` | fill | transportation | Piers | 54 - | `*-road_pier` | line | transportation | Pier lines | 55 - | `*-road_bridge_area` | fill | transportation | Bridge areas | 56 - | `*-road_path` | line | transportation | Paths/tracks | 57 - | `*-road_minor` | line | transportation | Minor roads | 58 - | `*-tunnel_minor` | line | transportation | Minor road tunnels | 59 - | `*-tunnel_major` | line | transportation | Major road tunnels | 60 - | `*-aeroway-area` | fill | aeroway | Runway/taxiway areas | 61 - | `*-aeroway-taxiway` | line | aeroway | Taxiways | 62 - | `*-aeroway-runway` | line | aeroway | Runways | 63 - | `*-road_trunk_primary` | line | transportation | Trunk/primary roads | 64 - | `*-road_secondary_tertiary` | line | transportation | Secondary/tertiary roads | 65 - | `*-road_major_motorway` | line | transportation | Motorways | 66 - | `*-railway-transit` | line | transportation | Transit lines | 67 - | `*-railway` | line | transportation | Railways | 68 - | `*-waterway-bridge-case` | line | waterway | River bridge outlines | 69 - | `*-waterway-bridge` | line | waterway | River bridges | 70 - | `*-bridge_minor case` | line | transportation | Minor road bridge outlines | 71 - | `*-bridge_major case` | line | transportation | Major road bridge outlines | 72 - | `*-bridge_minor` | line | transportation | Minor road bridges | 73 - | `*-bridge_major` | line | transportation | Major road bridges | 74 - | `*-admin_sub` | line | boundary | Sub-national boundaries | 75 - | `*-admin_country_z0-4` | line | boundary | Country boundaries (z0-4) | 76 - | `*-admin_country_z5-` | line | boundary | Country boundaries (z5+) | 77 - | `*-poi_label` | symbol | poi | POI labels | 78 - | `*-airport-label` | symbol | aerodrome_label | Airport labels | 79 - | `*-road_major_label` | symbol | transportation_name | Road names | 80 - | `*-place_label_town` | symbol | place | Town labels | 81 - | `*-place_label_suburb` | symbol | place | Suburb labels | 82 - | `*-place_label_city` | symbol | place | City labels | 83 - | `*-country_label` | symbol | place | Country labels | 135 + | # | Layer ID | Type | Source-layer | Notes | 136 + | -- | ------------------ | ------ | ------------ | ------------------------------------------------------------------------------ | 137 + | 1 | `water` | fill | water | Oceans + inland water | 138 + | 2 | `landcover` | fill | landcover | **Merged** grass, wood, wetland, sand, rock, ice — class-based color + opacity | 139 + | 3 | `park` | fill | park | National parks, nature reserves | 140 + | 4 | `waterway` | line | waterway | z6+, named rivers | 141 + | 5 | `boundary-state` | line | boundary | z3+, admin_level 3–6 | 142 + | 6 | `boundary-country` | line | boundary | admin_level 2 | 143 + | 7 | `place-continent` | symbol | place | z0–3, uppercase | 144 + | 8 | `place-country` | symbol | place | maxzoom 7, Medium font for sovereign states | 145 + | 9 | `place-state` | symbol | place | z4–8, uppercase | 146 + | 10 | `place-settlement` | symbol | place | **Merged** city + town + village, z6–10 (regional takes over at z11) | 84 147 85 - ### 3. Bookmark Layers 148 + ### Bookmark layers 86 149 87 - User-created bookmarks rendered on top of all map layers. 150 + User bookmarks rendered on top of all map layers. 151 + `#ensureBookmarkLayersOnTop()` re-moves them to the top whenever a new tile 152 + source is added. 88 153 89 - | Layer ID | Type | Description | 154 + | Layer ID | Type | Purpose | 90 155 | ------------------------------- | ------ | --------------------------------------- | 91 - | `bookmarks-clusters` | circle | Clustered bookmarks (5+ points) | 156 + | `bookmarks-clusters` | circle | Clustered bookmark pins (5+ points) | 92 157 | `bookmarks-cluster-count` | circle | Invisible hit area for clusters | 93 - | `bookmarks-cluster-count-label` | symbol | Cluster count labels | 158 + | `bookmarks-cluster-count-label` | symbol | Cluster count text | 94 159 | `bookmarks` | circle | Individual bookmark pins | 95 160 | `bookmarks-hit` | circle | Invisible larger hit area for bookmarks | 96 161 97 - The bookmark layers are explicitly moved to the top of the layer stack in `#ensureBookmarkLayersOnTop()` to ensure they render above map tiles and labels. 162 + ## Design decisions 163 + 164 + ### World and regional dovetail at z5 165 + 166 + Country boundaries: regional `admin_country` sets `minzoom: 5` so it only 167 + kicks in where world tiles stop producing meaningful boundary detail. Without 168 + this, country boundaries would render twice from z5 onward. 169 + 170 + Other regional layers (water, landcover, …) technically cover z0–14 but are 171 + only attached to the map after a region is downloaded, and at low zooms the 172 + regional source emits very little data due to `SetMinZoomByArea` throttling 173 + in `process.lua`. 174 + 175 + ### Label handoff between world and regional 176 + 177 + Country and settlement labels hand off cleanly instead of overlapping: 98 178 99 - ## Layer Loading Order 179 + | Label | World | Regional | 180 + | ---------- | ------------------------------ | -------------------------------- | 181 + | country | `place-country` z0–6 | `country_label` z7–11 | 182 + | settlement | `place-settlement` z6–10 | `place_label` z11–15 | 100 183 101 - 1. **World tiles** load first via `#loadWorldTiles()` - provides low-res global coverage 102 - 2. **Detail tiles** load on demand via `#loadCachedDetailTiles()` - higher resolution for downloaded regions 103 - 3. **Bookmark layers** are added last and moved to top 184 + The regional layer's `minzoom` is set to the same value as the world layer's 185 + `maxzoom` (exclusive), so there is no zoom at which both render. The tradeoff: 186 + if no region is downloaded, country labels disappear at z7+ and settlement 187 + labels disappear at z11+ — acceptable because at those zooms the map has 188 + little else to show without regional data. 104 189 105 - ## Z-Index in DOM 190 + ### Why some layers are NOT merged 106 191 107 - The CSS establishes the following z-index stacking: 192 + **`line-dasharray` is not data-driven** in MapLibre GL — it's a constant per 193 + layer. This prevents merging layers that need different dash patterns (e.g. 194 + `waterway` vs `waterway_intermittent`, `road_path` vs solid roads). 195 + 196 + **Render order matters for bridge casing.** The `bridge_case` layer must 197 + render below `bridge` to create the outline effect. They can't be a single 198 + layer. 199 + 200 + **Source-layer boundaries.** Layers using different source-layers (e.g. 201 + `waterway` vs `transportation`) cannot be merged even if they look similar. 202 + 203 + **Opacity ramps diverge too much.** Ice-shelf (constant 0.8), glacier (z0→z8 204 + ramp), and national park (z5→z9 ramp) have fundamentally different opacity 205 + behavior. Merging into one expression with multiple zoom stops adds 206 + maintenance cost for minimal gain (3 → 1 layer, but these are simple fills). 207 + 208 + ### Merge patterns used 209 + 210 + **`match` on feature property** — most common. Used for per-class color, 211 + line-width, text-size, opacity. Example: landcover uses 212 + `['match', ['get', 'class'], 'sand', 'rgba(...)', 'hsl(...)']`. 213 + 214 + **`interpolate` + `match` composite** — for zoom-dependent values that vary by 215 + class. The outer `interpolate` drives the zoom, and each stop uses a `match` 216 + to return the class-specific value. Example: road `line-width` varies by both 217 + zoom and road class. 218 + 219 + **`case` on feature property** — for boolean conditions. Example: water 220 + opacity uses `['case', ['==', ['get', 'intermittent'], 1], 0.7, 1]`. 221 + 222 + **`text-size: 0` to hide features** — for per-class minzoom within a merged 223 + symbol layer. MapLibre skips rendering features with text-size 0, so this 224 + effectively implements per-class visibility without separate layers (used in 225 + `place_label`). 226 + 227 + ### CSS z-index 228 + 229 + Container and controls are stacked so map controls sit above the canvas 230 + without breaking DOM order: 108 231 109 232 - `m-map` container: `z-index: 1` 110 233 - Map canvas: `z-index: 1` 111 234 - MapLibre controls: `z-index: 10` 112 235 - Download button: `z-index: 10` 113 - 114 - This ensures controls and buttons appear above the map canvas while maintaining the correct DOM order.
+21 -9
www/static/styles/theme.css
··· 59 59 overflow: hidden; 60 60 } 61 61 62 - body:has(r-home) > main { 62 + body:has(r-home)>main { 63 63 overflow: hidden; 64 64 } 65 65 ··· 99 99 z-index: 100; 100 100 } 101 101 102 - m-map > .download-btn { 102 + m-map>.download-btn { 103 103 z-index: 10; 104 104 } 105 105 ··· 232 232 margin-bottom: var(--s3); 233 233 } 234 234 235 - r-settings section > p, 236 - r-settings-downloads section > p, 237 - r-settings-about section > p { 235 + r-settings section>p, 236 + r-settings-downloads section>p, 237 + r-settings-about section>p { 238 238 opacity: 0.6; 239 239 margin-bottom: var(--s3); 240 240 font-size: var(--f5); ··· 424 424 transform: none; 425 425 } 426 426 427 - .search-history-item > span { 427 + .search-history-item>span { 428 428 flex: 1; 429 429 min-width: 0; 430 430 overflow: hidden; ··· 432 432 white-space: nowrap; 433 433 } 434 434 435 - .search-history-item > img { 435 + .search-history-item>img { 436 436 flex-shrink: 0; 437 437 opacity: 0.35; 438 438 } ··· 754 754 border-radius: var(--br-base); 755 755 } 756 756 757 - .bm-import-group + .bm-import-group { 757 + .bm-import-group+.bm-import-group { 758 758 border-top: 1px solid currentColor; 759 759 } 760 760 ··· 775 775 gap: 1px; 776 776 } 777 777 778 - .bm-import-item + .bm-import-item { 778 + .bm-import-item+.bm-import-item { 779 779 border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); 780 780 } 781 781 ··· 792 792 } 793 793 794 794 @media (max-width: 768px) { 795 + 795 796 input, 796 797 textarea, 797 798 select { ··· 804 805 m-map[hidden] .maplibregl-control-container .maplibregl-ctrl-attrib { 805 806 visibility: hidden; 806 807 } 808 + 809 + .zoom-display { 810 + position: absolute; 811 + bottom: 80px; 812 + left: 10px; 813 + background: rgba(255, 255, 255, 0.9); 814 + padding: 4px 8px; 815 + border-radius: 4px; 816 + font-size: 12px; 817 + z-index: 1; 818 + }
-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.
+24 -15
www/utils/layers.ts
··· 6 6 const ALL = 'all' 7 7 const LINE = ['==', '$type', 'LineString'] 8 8 const POLYGON = ['==', '$type', 'Polygon'] 9 - const MINOR_ROAD = ['minor_road', 'primary', 'secondary', 'tertiary', 'trunk'] 9 + const MINOR_ROAD = ['minor', 'primary', 'secondary', 'tertiary', 'trunk'] 10 10 11 11 const ELEVEN_SIXTEEN = { 'base': 1, 'stops': [[8, 0], [16, 1]] } 12 12 ··· 26 26 'source': sourceName, 27 27 'source-layer': 'landuse', 28 28 'minzoom': 11, 29 - 'filter': [ 30 - ALL, 31 - POLYGON, 32 - ['in', 'class', 'residential', 'suburb', 'neighbourhood'], 33 - ], 29 + 'filter': [ALL, POLYGON, ['==', 'class', 'residential']], 34 30 'layout': { 'visibility': 'visible' }, 35 31 'paint': { 'fill-color': 'hsl(47, 13%, 86%)', 'fill-opacity': 0.7 }, 36 32 }, ··· 99 95 'id': `${sourceName}-landuse`, 100 96 'type': 'fill', 101 97 'source': sourceName, 102 - 'source-layer': 'landuse', 103 - 'filter': ['==', 'class', 'agriculture'], 98 + 'source-layer': 'landcover', 99 + 'filter': ['==', 'class', 'farmland'], 104 100 'layout': { 'visibility': 'visible' }, 105 101 'paint': { 'fill-color': '#eae0d0' }, 106 102 }, ··· 108 104 'id': `${sourceName}-landuse_overlay_national_park`, 109 105 'type': 'fill', 110 106 'source': sourceName, 111 - 'source-layer': 'landcover', 112 - 'filter': ['==', 'class', 'national_park'], 107 + 'source-layer': 'park', 108 + 'filter': ['in', 'class', 'national_park', 'nature_reserve'], 113 109 'paint': { 114 110 'fill-color': '#E1EBB0', 115 - 'fill-opacity': { 'base': 1, 'stops': [[5, 0], [9, 0.75]] }, 111 + 'fill-opacity': { 'base': 1, 'stops': [[11, 0], [13, 0.75]] }, 116 112 }, 117 113 }, 118 114 { ··· 185 181 'paint': { 186 182 'fill-antialias': true, 187 183 'fill-color': 'rgba(222, 211, 190, 1)', 188 - 'fill-opacity': { 'base': 1, 'stops': [[11, 0], [13, 1]] }, 184 + 'fill-opacity': { 'base': 1, 'stops': [[12, 0], [13, 1]] }, 189 185 'fill-outline-color': { 190 186 'stops': [ 191 - [11, 'rgba(212, 177, 146, 0)'], 187 + [12, 'rgba(212, 177, 146, 0)'], 192 188 [13, 'rgba(212, 177, 146, 0.5)'], 193 189 ], 194 190 }, ··· 276 272 'line-color': [ 277 273 'match', 278 274 ['get', 'class'], 279 - 'minor_road', 275 + 'minor', 280 276 '#efefef', 281 277 '#fff', 282 278 ], ··· 371 367 'layout': { 'line-cap': 'round', 'line-join': 'round' }, 372 368 'paint': { 373 369 'line-color': '#fff', 370 + 'line-opacity': [ 371 + 'interpolate', 372 + ['linear'], 373 + ['zoom'], 374 + 12, 375 + ['match', ['get', 'class'], 'tertiary', 0, 'minor', 0, 'service', 0, 1], 376 + 13, 377 + ['match', ['get', 'class'], 'minor', 0, 'service', 0, 1], 378 + 14, 379 + 1, 380 + ], 374 381 'line-width': [ 375 382 'interpolate', 376 383 ['exponential', 1.4], ··· 526 533 'line-color': [ 527 534 'match', 528 535 ['get', 'class'], 529 - 'minor_road', 536 + 'minor', 530 537 '#efefef', 531 538 '#fff', 532 539 ], ··· 725 732 'type': 'symbol', 726 733 'source': sourceName, 727 734 'source-layer': 'place', 735 + 'minzoom': 7, 728 736 'maxzoom': 12, 729 737 'filter': [ALL, ['==', '$type', 'Point'], ['==', 'class', 'country']], 730 738 'layout': { ··· 990 998 ['literal', ['city', 'town', 'village']], 991 999 ], 992 1000 'minzoom': 6, 1001 + 'maxzoom': 11, 993 1002 'layout': { 994 1003 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 995 1004 'text-font': ['Noto Sans Regular'],