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
+43 -49
View File
@@ -23,40 +23,31 @@ explicit cache headers, so an edit to `public/` (or `views/`) can
appear not to have taken effect on the next reload. Force a real
reload (e.g. Ctrl/Cmd+Shift+R) if that happens.
`/theme_preview.html` is a standalone debug page (not linked from the
app itself) for comparing offline base-map themes side by side: a bare
`protomaps-themes-base` theme, an iframe of the real app's offline
style, and one of its online style, with pan/zoom kept in sync across
all three via the same URL-hash mechanism `map.js` already uses for
shareable links. It's a plain static file, so nothing gates it to
development — it's reachable on a production instance too, same as any
other file under `public/`. Harmless if it stays that way (it shows
nothing `/` doesn't already), but worth knowing if that's ever not the
case.
== Offline map data
To enable the offline style, place a PMTiles file at
`data/offline.pmtiles` (see `docs/architecture.adoc` for how it is
served and styled). Logged-in users can generate this from the app
itself — see "In-app regeneration" below — or it can be built by hand:
served and styled — the same OpenMapTiles schema/style the online map
uses, just from local data). Logged-in users can generate this from
the app itself — see "In-app regeneration" below — or it can be built
by hand:
* Extract a region from Protomaps' own daily full-planet basemap build
(https://build-metadata.protomaps.dev/builds.json[build index],
files at `https://build.protomaps.com/<key>`, ~120GB, ODbL/OSM
attribution) with the
https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI's `extract`
subcommand, which pulls only the byte ranges covering a bbox over
HTTP range requests rather than the whole file:
`pmtiles extract https://build.protomaps.com/<key> data/offline.pmtiles --bbox=west,south,east,north --maxzoom=15`.
(The old `app.protomaps.com` extract-builder UI this section used to
point at has since redirected elsewhere — this is the same
underlying mechanism, just the CLI instead of a web UI.)
* To convert some other vector tile source (e.g. an `.mbtiles` file)
into PMTiles instead, use the same CLI's `convert` subcommand:
`pmtiles convert in.mbtiles data/offline.pmtiles`. Its schema is
what `protomaps-themes-base` (the styling library used for the
offline style) expects.
* Download one or more country extracts from
https://download.geofabrik.de[Geofabrik] (`.osm.pbf`, ODbL/OSM
attribution) — e.g.
`curl -O https://download.geofabrik.de/europe/slovakia-latest.osm.pbf`.
* If more than one country, merge them with
https://github.com/osmcode/osmium-tool[osmium-tool]:
`osmium merge slovakia-latest.osm.pbf austria-latest.osm.pbf -o merged.osm.pbf`
(each Geofabrik file is already clipped to that country's real
border, so no separate bbox/polygon clip step is needed either
before or after — skip this step entirely for a single country).
* Tile it with https://github.com/onthegomap/planetiler[Planetiler]'s
default profile, which *is* OpenMapTiles — no `--profile` flag
needed — writing PMTiles directly:
`java -jar planetiler.jar --osm-path=merged.osm.pbf --output=data/offline.pmtiles`
(needs Java 21+; `planetiler.jar` from
https://github.com/onthegomap/planetiler/releases/latest[the latest release]).
Once the file is in place, `GET /api/offline-tiles` reports
`available: true` and the map defaults to the offline style.
@@ -113,14 +104,15 @@ just what changed — fine given extraction is fast (byte-range requests
against remote archives, not per-tile fetches), but worth revisiting if
someone selects half of Europe at once and finds it slow.
This wraps exactly the manual `go-pmtiles` workflows documented above
(`lib/maptool/pmtiles_builder.rb`) — the server needs the
https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI installed and
on `PATH` (override with the `PMTILES_BIN` env var if it lives
somewhere else), using `--region=<geojson>` (a `FeatureCollection` of
the selected countries — go-pmtiles treats multiple features as their
union already, no union/dissolve math needed on this side) rather than
`--bbox=` for precise, per-country extraction. terrain.pmtiles extracts
This wraps exactly the manual workflows documented above
(`lib/maptool/pmtiles_builder.rb`). offline.pmtiles downloads each
selected country's Geofabrik extract, `osmium merge`s them if there's
more than one, then tiles the result with Planetiler — the server
needs `osmium` and `java` + `planetiler.jar` installed and on `PATH`
(override with the `OSMIUM_BIN`/`JAVA_BIN`/`PLANETILER_JAR` env vars if
they live somewhere else; the Docker image installs all three, see
`Dockerfile`). terrain.pmtiles still uses `go-pmtiles`
(`PMTILES_BIN`) against Mapterhorn, unrelated to this — it extracts
`planet.pmtiles` (always) plus whichever Mapterhorn regional chunks
intersect the selection, `merge`s them if there was more than one —
always safe regardless of how many/which countries are selected, since
@@ -139,18 +131,20 @@ starting a second while one's running gets a 409, as does starting
either with nothing selected yet.
Each successful build also writes a small sidecar (`offline.pmtiles.
meta.json` / `terrain.pmtiles.meta.json` — the Protomaps build date used,
or the Mapterhorn source names/md5sums) recording what was actually
built, since the `.pmtiles` file itself carries no such record. `GET
/api/offline-tiles`/`/api/terrain-tiles` include it as `meta`, shown in
the panel next to the existing size/updated line. `GET .../check-update`
compares that recorded build against the live upstream index/manifest
(without rebuilding anything) and reports `update_available: true/false/
null` — `null` means "don't know" (no local build yet, or the upstream
check itself failed), kept as its own route rather than folded into the
plain status endpoints above since those are on the page-load hot path
(the topbar's offline-status dot) and this one reaches an external
service, only worth doing when the Offline data panel is actually open.
meta.json` — each source country's code/Geofabrik slug/Last-Modified;
`terrain.pmtiles.meta.json` — the Mapterhorn source names/md5sums)
recording what was actually built, since the `.pmtiles` file itself
carries no such record. `GET /api/offline-tiles`/`/api/terrain-tiles`
include it as `meta`, shown in the panel next to the existing
size/updated line. `GET .../check-update` compares that recorded build
against each source's live upstream state (a Geofabrik `HEAD` per
country for offline, the Mapterhorn manifest for terrain — without
rebuilding anything) and reports `update_available: true/false/null` —
`null` means "don't know" (no local build yet, or a check itself
failed), kept as its own route rather than folded into the plain
status endpoints above since those are on the page-load hot path (the
topbar's offline-status dot) and this one reaches an external service,
only worth doing when the Offline data panel is actually open.
== Camper-relevant POIs (Overpass)
+6 -6
View File
@@ -24,8 +24,8 @@ checking here before searching from zero next time.
| MIT (per `package.json`); the whole `npmap5-plugins` repo is separately
public domain as a US government work
| Park Tiles color palette (offline + online basemap themes)
| `public/js/map.js`'s `OFFLINE_THEME` and `ONLINE_STYLE_PATCH`
| Park Tiles color palette (shared offline + online basemap theme)
| `public/js/map.js`'s `ONLINE_STYLE_PATCH`
| Colors only — see <<park-tiles>> for why the actual tile service isn't used
| `overview` (top-right minimap/inset control)
@@ -187,7 +187,7 @@ why this app uses OpenFreeMap Liberty as its actual basemap, not Park
Tiles — see `docs/architecture.adoc`'s "Offline maps" section.
Recreating the *color choices* is a different matter — not the kind of
thing a data license restricts — and is what `OFFLINE_THEME` in
thing a data license restricts — and is what `ONLINE_STYLE_PATCH` in
`public/js/map.js` actually does (see `docs/architecture.adoc`).
=== Public style JSON (source for the real colors we did pull)
@@ -219,11 +219,11 @@ embedded credentials, even when technically reachable:
|===
Colors actually pulled from the Imagery style JSON and used in
`OFFLINE_THEME`: background (`hsl(40, 37%, 97%)`, a warm cream), the
`ONLINE_STYLE_PATCH`: background (`hsl(40, 37%, 97%)`, a warm cream), the
park/protected-area green (`#b3e378`), the two road-importance tiers
(`#fdefbe`/`#fef7dd`), and the park-boundary line color (`#546619`, dark
olive). The rest of `OFFLINE_THEME` extends that same warm/olive family
by eye rather than from a sourced value.
olive). The rest of `ONLINE_STYLE_PATCH` extends that same warm/olive
family by eye rather than from a sourced value.
=== Legend / cartographic hierarchy
+41 -21
View File
@@ -18,10 +18,6 @@ BSD-3-Clause licensed.
| 4.5.0
| https://github.com/protomaps/PMTiles
| protomaps-themes-base
| 4.5.0
| https://github.com/protomaps/basemaps
| maplibre-contour (Overlays panel's "Contour lines", `public/js/contour.js`)
| 0.1.0
| https://github.com/onthegomap/maplibre-contour
@@ -91,38 +87,62 @@ for free use under the SIL Open Font License" per the site; no
`OFL.txt` ships in its own download zips, unlike Noto Sans above
|===
The online map style (not vendored, fetched at runtime when the
"Online" layer is selected) is
https://tiles.openfreemap.org/styles/liberty[OpenFreeMap "liberty"],
built from https://www.openstreetmap.org[OpenStreetMap] data.
The map style — https://tiles.openfreemap.org/styles/liberty[OpenFreeMap
"liberty"], built from https://www.openstreetmap.org[OpenStreetMap]
data — is used two ways (see `docs/architecture.adoc`): fetched live
at runtime for the "Online" layer, and as a one-time vendored snapshot
(`public/vendor/liberty-style/liberty.json`) that the "Offline" layer
repoints at local data instead. Its icon sprite is vendored the same
way, under `public/vendor/liberty-sprite/` (`ofm.json`/`ofm.png` +
`@2x` variants, from `tiles.openfreemap.org/sprites/`). Neither has
been re-checked against a specific upstream version pin the way the
table above does — re-fetch both from `tiles.openfreemap.org` if
Liberty's own style/sprite ever visibly drifts from what's vendored.
`terrain.pmtiles` (`lib/maptool/pmtiles_builder.rb`'s in-app
`data/offline.pmtiles` (`lib/maptool/pmtiles_builder.rb`'s in-app
regeneration, or the manual recipe in `docs/development.adoc`) is
extracted at build time, not vendored, from
https://mapterhorn.com[Mapterhorn] — a Protomaps-affiliated project
publishing pre-built Terrarium-encoded terrain as PMTiles. Per-region
source licenses vary (CC-BY-4.0 and similar); see
built at build time, not vendored, from
https://download.geofabrik.de[Geofabrik]'s per-country OpenStreetMap
extracts (ODbL).
`terrain.pmtiles` (same regeneration mechanism) is extracted at build
time, not vendored, from https://mapterhorn.com[Mapterhorn] — a
Protomaps-affiliated project publishing pre-built Terrarium-encoded
terrain as PMTiles. Per-region source licenses vary (CC-BY-4.0 and
similar); see
https://download.mapterhorn.com/attribution.json[their own attribution
list] for the full provenance.
Not a frontend asset like the above, but also baked into this app's own
Docker image (not a separate container the way kiwix-serve/gpsd are —
see `docs/deployment.adoc`/`docs/development.adoc`): the `pmtiles` CLI
(`go-pmtiles`), installed as a static binary in the `Dockerfile` for
`lib/maptool/pmtiles_builder.rb`'s in-app offline.pmtiles/terrain.pmtiles
regeneration. BSD-3-Clause, same as pmtiles.js/MapLibre GL above.
see `docs/deployment.adoc`/`docs/development.adoc`): three CLI tools
`lib/maptool/pmtiles_builder.rb` shells out to for offline.pmtiles/
terrain.pmtiles regeneration.
[cols="1,1,2"]
[cols="1,1,1,2"]
|===
| Tool | Version | Source
| Tool | Version | License | Source
| go-pmtiles (`pmtiles` CLI)
| 1.31.2
| BSD-3-Clause
| https://github.com/protomaps/go-pmtiles
| Planetiler (`planetiler.jar`)
| 0.10.2
| Apache-2.0
| https://github.com/onthegomap/planetiler
| osmium-tool (`osmium`, installed via the `osmium-tool` Debian package)
| (Debian bookworm's packaged version)
| GPL-3.0
| https://github.com/osmcode/osmium-tool
|===
To update a vendored library: download its package (e.g.
`npm pack <name>@<version>`), replace the corresponding files under
`public/vendor/<name>/`, and update the version in this file. To
update go-pmtiles, bump `PMTILES_VERSION` in the `Dockerfile` and the
version here.
update go-pmtiles or Planetiler, bump `PMTILES_VERSION`/
`PLANETILER_VERSION` in the `Dockerfile` and the version here —
osmium-tool tracks whatever version Debian bookworm currently packages,
nothing to pin on this side.