Replace offline basemap pipeline with Planetiler, unify online/offline styling
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 2m43s

Offline tiles were built from Protomaps' own basemap schema, needing a
completely separate hand-tuned theme (protomaps-themes-base + OFFLINE_THEME)
from the online Liberty style's ONLINE_STYLE_PATCH — the two could never be
made to match exactly no matter how much either side got tuned (today's
session found and fixed several mismatches: building opacity, park
boundaries, road casings, road widths).

Offline.pmtiles is now built with Planetiler's default OpenMapTiles profile
against Geofabrik per-country OSM extracts (osmium-merged when more than one
country is selected), the same vector schema Liberty already expects.
offlineStyle() now clones the same Liberty JSON (vendored as a local
snapshot for reliability with no internet) and applies the identical
ONLINE_STYLE_PATCH, just repointed at local PMTiles/glyphs/sprite — one
palette instead of two, correct on both sides by construction.

Also vendors Liberty's icon sprite for the first time (offline had none
before), adds Java/osmium-tool/planetiler.jar to the Docker image, and
removes the now-unused protomaps-themes-base dependency and the
theme-comparison debug page that found today's bugs.
This commit is contained in:
randy committed 2026-09-11 20:52:11 +02:00
1 parent 0874e8f8ed
commit d08ed41185
21 files changed
+4286 -512

No files matched your search

+70 -62
View File
@@ -44,18 +44,33 @@ opened in a browser.
Map data is vector, not raster: the app ships
https://maplibre.org[MapLibre GL JS] (vendored, see below) and the map
view (`public/js/map.js`) offers two styles:
view (`public/js/map.js`) offers two styles, both **the same
OpenMapTiles-schema Liberty style** (https://tiles.openfreemap.org/styles/liberty[OpenFreeMap
"liberty"]), just pointed at different vector sources:
* *Online* — a full hosted vector style,
https://tiles.openfreemap.org/styles/liberty[OpenFreeMap "liberty"],
* *Online* — Liberty's own hosted tiles, `tiles.openfreemap.org`,
requires internet access.
* *Offline* — built at runtime from a local
https://github.com/protomaps/PMTiles[PMTiles] archive at
`data/offline.pmtiles`, served as-is (see below) at
`/tiles/offline.pmtiles`, decoded client-side by
* *Offline* — a local https://github.com/protomaps/PMTiles[PMTiles]
archive at `data/offline.pmtiles`, built by
https://github.com/onthegomap/planetiler[Planetiler] from an OSM
extract (see `docs/development.adoc`), served as-is at
`/tiles/offline.pmtiles` and decoded client-side by
https://github.com/protomaps/PMTiles[pmtiles.js] registered as a
`pmtiles://` protocol handler, and styled with base layers from
https://github.com/protomaps/basemaps[protomaps-themes-base].
`pmtiles://` protocol handler.
Using the same schema both ways is deliberate: an earlier version of
this app built offline tiles from Protomaps' own basemap schema
instead, which needed a *separate* hand-tuned theme
(`protomaps-themes-base` + a since-removed `OFFLINE_THEME`) to
approximate the online look — two independently-maintained palettes
that drifted out of sync (building opacity, road casings, park
boundaries) every time one side changed. Planetiler's default profile
*is* OpenMapTiles, so `offlineStyle()` now just clones the same parsed
Liberty JSON `ONLINE_STYLE` already fetched, repoints its `sources`,
`glyphs`, and `sprite` at local copies, and applies the exact same
`ONLINE_STYLE_PATCH` recolor (below) — one palette, one place to tune
it, correct on both sides by construction rather than by re-checking
every time something changes.
`GET /api/offline-tiles` reports whether `data/offline.pmtiles` is
present (`{"available": true|false}`); the frontend uses it once, on
@@ -72,28 +87,35 @@ support) just returns byte ranges of the file; pmtiles.js in the
browser reads the archive's own directory and requests only the bytes
it needs for the tiles on screen.
=== Offline text labels and color palette
No PMTiles file ships with the repository — it is built locally or
in-app (see `docs/development.adoc`) and stays gitignored because such
extracts are typically large binary files. Geofabrik's per-country
extracts are OSM data, ODbL-licensed, same attribution
(`© OpenStreetMap contributors`) the map already shows either way — no
separate licensing story the way the old Protomaps-basemap pipeline's
NPS-Park-Tiles color research needed.
=== Offline text labels, sprite, and color palette
Label rendering in MapLibre needs a "glyphs" server serving font PBF
ranges (256 Unicode code points each); `data/offline.pmtiles`'s fields
(`name`, `name:en`, `script`, ...) match what
`protomaps-themes-base`'s `layers(source, theme, { lang: "en" })`
expects for its label layers (verified against the actual PMTiles
vector layer schema), so passing that option turns labels on. The
glyphs themselves are vendored under `public/vendor/fonts/` — Noto
Sans Regular/Italic/Medium, ranges `0-255` and `256-511`
(Basic Latin + Latin-1 Supplement + Latin Extended-A, SIL OFL
licensed, from
ranges (256 Unicode code points each). The glyphs Liberty's style
references are vendored under `public/vendor/fonts/` — Noto Sans
Regular/Italic/Medium, ranges `0-255` and `256-511` (Basic Latin +
Latin-1 Supplement + Latin Extended-A, SIL OFL licensed, from
https://github.com/protomaps/basemaps-assets[protomaps/basemaps-assets]),
covering accented Latin place names (Slovak, Czech, Polish, German,
...). Labels in other scripts (Cyrillic, Greek, CJK, ...) won't render
offline, since those glyph ranges aren't vendored.
offline, since those glyph ranges aren't vendored. Liberty's icon
sprite (shields, POI icons) is likewise vendored as a one-time
snapshot under `public/vendor/liberty-sprite/` — both `offlineStyle()`
and the online style otherwise share identical `glyphs`/`sprite`
roles, just resolved to local vs. `tiles.openfreemap.org` URLs.
`public/js/map.js`'s `OFFLINE_THEME` nudges a couple dozen color roles
from `protomaps-themes-base`'s stock "light" theme towards the
National Park Service's own "Park Tiles" cartography — a warm,
Harpers-Ferry-Center-derived look. A few of these values are the
genuine paint colors pulled from a public Park Tiles style JSON
`public/js/map.js`'s `ONLINE_STYLE_PATCH` — applied to whichever
source is loaded, online or offline (see above) — nudges Liberty's
OpenMapTiles layer ids towards a warm, National Park Service
"Park Tiles"-inspired look. A few of these values are the genuine
paint colors pulled from a public Park Tiles style JSON
(`nps.gov/lib/npmap.js/style/ck72fwp2642dv07o7tbqinvz4.json`, the
"Imagery" variant — the only one of NPS's four styles served from a
plain, unauthenticated URL; "Standard"/"Slate"/"Light" resolve through
@@ -101,50 +123,36 @@ Mapbox Studio's private API with an access token, which isn't
something to piggyback on even though it's technically visible in
their own JS bundle) — background, the park/protected-area green, the
two road-importance tiers, and the park-boundary line color. The rest
are reasoned to match that same warm family (olive greens, muted blue)
rather than sourced. This is deliberately partial — labels, casings,
and roles with no clean real-color anchor are left at theme
defaults — not the kind of full per-layer recolor a previous iteration
of this app did (and removed, along with any dependency on OsmAnd's
own tile server, which this app cannot use per OsmAnd's terms
restricting tile use to "personal, non-commercial use within OsmAnd
applications and platforms").
are reasoned to match that same warm family (olive greens, muted blue,
brown road casings) rather than sourced. This is deliberately
partial — labels, halos, rail, and minor overlays (cemetery/hospital/
school/pitch/track shading) are left at Liberty's own defaults.
+
Recreating the *color choices* is fine — that's not the kind of thing
a data license restricts — but the actual Park Tiles *service* stays
off-limits: its OSM/terrain layers are licensed through Mapbox Atlas
(a commercial, non-sublicensable data service scoped to NPS), and it's
built for park visitors specifically (park-unit and trailhead
labeling prioritized), not general-purpose worldwide cartography.
labeling prioritized), not general-purpose worldwide cartography —
irrelevant to this app either way now, since it never talks to NPS's
tile service, only ever OpenFreeMap/Geofabrik.
No PMTiles file ships with the repository — it is expected to be
supplied locally (see `docs/development.adoc`) and stays gitignored
because such extracts are typically large binary files.
The Online map (OpenFreeMap Liberty) gets the same treatment via
`ONLINE_STYLE_PATCH`, a separate hand-built table — Liberty is an
OpenMapTiles-schema style, with its own layer ids (`landcover_wood`,
`road_secondary_tertiary`, `boundary_2`, ...) that don't correspond to
protomaps' theme roles, so the two palettes can't share one object,
only the same underlying color choices. `loadOnlineStyle`
(`public/js/map.js`) fetches Liberty's `style.json` once, patches it
in place, and uses the resulting object everywhere `ONLINE_STYLE` used
to be a plain URL string — MapLibre accepts a style object anywhere it
accepts a style URL, so `applyStyle`'s `map.setStyle(... : ONLINE_STYLE)`
needed no change. That fetch runs before the map (and the rest of the
page) is constructed — a top-level `await` — so there's no flash of
Liberty's own colors before the patch lands; a 3s timeout and a
catch-all fall back to the plain URL (letting MapLibre fetch and
render it unpatched) rather than block the whole page's startup on a
remote style server, since this app is meant to keep working offline
in the field, independent of whether the online map itself loads.
Only the layers that dominate the map's overall color read are
patched (land cover, water, parks, roads, boundaries, buildings) —
same deliberately-partial scope as `OFFLINE_THEME`, not a full
per-layer recolor. Road casings are the one addition with no
protomaps-role equivalent to reuse, since offline roads don't get a
separate outline color there; `#c9a66b` is reasoned to fit the family
rather than sourced from anywhere real.
`loadOnlineStyle` (`public/js/map.js`) fetches Liberty's live
`style.json` once, patches it in place, and uses the resulting object
everywhere `ONLINE_STYLE` used to be a plain URL string — MapLibre
accepts a style object anywhere it accepts a style URL, so
`applyStyle`'s `map.setStyle(... : ONLINE_STYLE)` needed no change.
That fetch runs before the map (and the rest of the page) is
constructed — a top-level `await` — so there's no flash of Liberty's
own colors before the patch lands; a 3s timeout and a catch-all fall
back to the plain URL (letting MapLibre fetch and render it unpatched)
rather than block the whole page's startup on a remote style server.
Offline mode can't rely on that same live fetch succeeding — the field
usage this app is built for is exactly when it won't — so
`offlineStyle()` instead clones a vendored, point-in-time snapshot of
the same Liberty JSON (`public/vendor/liberty-style/liberty.json`),
patches it the same way, and repoints it at the local PMTiles/glyphs/
sprite described above.
== Map permalink