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

+17 -3
View File
@@ -3,9 +3,14 @@ FROM ruby:4.0.6-slim-bookworm
# build-essential: puma has a native (C) extension; harmless if the
# platform-specific gem in Gemfile.lock already has a precompiled binary
# matching this base image, but keeps the build working if it doesn't.
# ca-certificates/curl: needed for the go-pmtiles download below (also
# handy for anyone debugging inside a running container).
RUN apt-get update && apt-get install --no-install-recommends -y build-essential ca-certificates curl \
# ca-certificates/curl: needed for the go-pmtiles/planetiler.jar downloads
# below (also handy for anyone debugging inside a running container).
# default-jre-headless: Planetiler is a JVM tool (see PLANETILER_VERSION
# below) — headless since this never touches a display.
# osmium-tool: merges multiple countries' OSM extracts into one file before
# handing them to Planetiler (lib/maptool/pmtiles_builder.rb) — a normal
# Debian package, no separate download/pinning needed like the other two.
RUN apt-get update && apt-get install --no-install-recommends -y build-essential ca-certificates curl default-jre-headless osmium-tool \
&& rm -rf /var/lib/apt/lists/*
# The `pmtiles` CLI (lib/maptool/pmtiles_builder.rb's "Regenerate for
@@ -26,6 +31,15 @@ RUN set -eux; \
curl -fsSL "https://github.com/protomaps/go-pmtiles/releases/download/v${PMTILES_VERSION}/go-pmtiles_${PMTILES_VERSION}_Linux_${pmtiles_arch}.tar.gz" \
| tar xz -C /usr/local/bin pmtiles
# Planetiler (lib/maptool/pmtiles_builder.rb's offline.pmtiles build,
# replacing an earlier Protomaps-CDN-based pipeline — see
# docs/development.adoc) — a single JVM fat jar, no per-arch variant needed
# unlike go-pmtiles above. Default PLANETILER_JAR path (pmtiles_builder.rb)
# expects it here.
ARG PLANETILER_VERSION=0.10.2
RUN curl -fsSL "https://github.com/onthegomap/planetiler/releases/download/v${PLANETILER_VERSION}/planetiler.jar" \
-o /usr/local/lib/planetiler.jar
WORKDIR /app
COPY Gemfile Gemfile.lock ./
+1 -1
View File
@@ -8,7 +8,7 @@ independence from connectivity.
== Offline data and cartography
* [ ] Offline vector tiles (PMTiles/MBTiles) for Europe/world via Planetiler or MapTiler
* [x] Offline vector tiles (PMTiles/MBTiles) for Europe/world via Planetiler or MapTiler — Geofabrik per-country extracts, `osmium merge`, Planetiler's default OpenMapTiles profile straight to PMTiles (`lib/maptool/pmtiles_builder.rb`); offline now shares the exact same style/palette as online (`ONLINE_STYLE_PATCH`) instead of a separate protomaps-schema theme
* [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now
** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] Contour lines from OSM for hikes and mountain passes
+3 -4
View File
@@ -260,10 +260,9 @@ module Maptool
end
# Regenerates offline.pmtiles for the currently selected regions (see
# POST /api/offline-regions above) by extracting them from Protomaps'
# own daily full-planet basemap build — see
# lib/maptool/pmtiles_builder.rb. Runs in the background; poll
# build-status below for progress.
# POST /api/offline-regions above) by downloading each from Geofabrik
# and tiling them with Planetiler — see lib/maptool/pmtiles_builder.rb.
# Runs in the background; poll build-status below for progress.
post "/api/offline-tiles/build" do
require_login!
begin
+5 -1
View File
@@ -60,7 +60,11 @@ services:
# docs/development.adoc for how the deploy job treats this
# directory). A private plugin (app.rb's PLUGINS_DIRS) also belongs
# under here, at data/plugins/<name>/ — nothing further to mount,
# this one bind already covers the whole data/ tree.
# this one bind already covers the whole data/ tree. An offline.pmtiles
# build's own scratch work (Dir.mktmpdir, lib/maptool/pmtiles_builder.rb)
# happens outside this mount, in the container's own filesystem — a
# large country's .osm.pbf plus Planetiler's working set can be
# several GB, worth knowing if the host is ever short on space there.
- ./data:/app/data
# Fakes a GNSS fix (a loop around Bratislava, see
+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.
+193 -80
View File
@@ -9,12 +9,10 @@ module Maptool
# Regenerates data/offline.pmtiles and data/terrain.pmtiles for the
# currently *selected* regions (data/offline_regions.json, an array of
# Natural Earth iso_a2 country codes — see available_regions/
# selected_regions/set_selected_regions below) by shelling out to
# go-pmtiles — the same tool docs/development.adoc already documents
# running by hand. No job queue: each build runs on its own background
# Thread and reports into a Mutex-guarded status Hash a route can poll,
# the same shape app.rb's own GNSS poller (GNSS_LATEST/GNSS_LATEST_MUTEX)
# already uses.
# selected_regions/set_selected_regions below). No job queue: each build
# runs on its own background Thread and reports into a Mutex-guarded
# status Hash a route can poll, the same shape app.rb's own GNSS poller
# (GNSS_LATEST/GNSS_LATEST_MUTEX) already uses.
module PmtilesBuilder
class BuildError < StandardError; end
@@ -22,24 +20,71 @@ module Maptool
# countries, filtered to Europe and trimmed to name/iso_a2/iso_a3 —
# see the plan this was built from for the exact filter. 110m is
# deliberately coarse (a km or so of border precision, not
# survey-grade) — fine for "which tiles to grab," a same-schema swap
# survey-grade) — fine for "which country to grab," a same-schema swap
# to Natural Earth's 50m/10m cut if that ever needs to be tighter.
REGIONS_FILE = File.join(__dir__, "data", "europe_countries.geojson")
# Protomaps' own daily full-planet basemap build index — extract_offline
# below only pulls the HTTP byte ranges covering the requested region
# (go-pmtiles's `extract` supports a remote source over range requests),
# never the whole ~120GB file.
PROTOMAPS_BUILDS_INDEX = "https://build-metadata.protomaps.dev/builds.json"
PROTOMAPS_BUILD_BASE = "https://build.protomaps.com"
# Every iso_a2 code in REGIONS_FILE mapped to its Geofabrik download
# slug (https://download.geofabrik.de/<slug>-latest.osm.pbf) — Geofabrik
# names regions, not ISO codes, and doesn't always split the way a
# country border does, so this table exists rather than deriving the
# slug from the country name. Two known quirks worth knowing about,
# not worth working around:
# - RU has no "europe/" prefix (Russia is its own top-level index,
# and unlike every other entry here is a single ~4GB file covering
# the whole country rather than a European-only slice).
# - GB's "great-britain" slug is England/Scotland/Wales only —
# Northern Ireland lives in "ireland-and-northern-ireland" instead
# (bundled with IE, not GB). Selecting GB alone won't include NI.
GEOFABRIK_SLUGS = {
"AL" => "europe/albania",
"AT" => "europe/austria",
"BY" => "europe/belarus",
"BE" => "europe/belgium",
"BA" => "europe/bosnia-herzegovina",
"BG" => "europe/bulgaria",
"HR" => "europe/croatia",
"CZ" => "europe/czech-republic",
"DK" => "europe/denmark",
"EE" => "europe/estonia",
"FI" => "europe/finland",
"FR" => "europe/france",
"DE" => "europe/germany",
"GR" => "europe/greece",
"HU" => "europe/hungary",
"IS" => "europe/iceland",
"IE" => "europe/ireland-and-northern-ireland",
"IT" => "europe/italy",
"XK" => "europe/kosovo",
"LV" => "europe/latvia",
"LT" => "europe/lithuania",
"LU" => "europe/luxembourg",
"MD" => "europe/moldova",
"ME" => "europe/montenegro",
"NL" => "europe/netherlands",
"MK" => "europe/macedonia",
"NO" => "europe/norway",
"PL" => "europe/poland",
"PT" => "europe/portugal",
"RO" => "europe/romania",
"RU" => "russia",
"RS" => "europe/serbia",
"SK" => "europe/slovakia",
"SI" => "europe/slovenia",
"ES" => "europe/spain",
"SE" => "europe/sweden",
"CH" => "europe/switzerland",
"UA" => "europe/ukraine",
"GB" => "europe/great-britain",
}.freeze
# Mapterhorn (a Protomaps-affiliated project) publishes Terrarium-
# encoded terrain as pre-built PMTiles archives: one global
# planet.pmtiles (z0-12) plus ~450 regional chunks (z13 up to a
# per-region max, 13-18) — same "extract only the region's byte ranges
# from a remote archive" story as offline.pmtiles above, so no more
# fetching individual tiles or building an MBTiles file ourselves the
# way an earlier version of this file did.
# per-region max, 13-18) — extract_offline below no longer touches
# Protomaps at all (see GEOFABRIK_SLUGS above), but terrain stays on
# this same "extract only the region's byte ranges from a remote
# archive" story via go-pmtiles, unrelated to the basemap swap.
MAPTERHORN_MANIFEST_URL = "https://download.mapterhorn.com/download_urls.json"
MAPTERHORN_PLANET_NAME = "planet.pmtiles"
# Matches the online hillshade layer's own cap (public/js/overlays.js's
@@ -102,28 +147,22 @@ module Maptool
end
private_class_method :region_features
# Writes the FeatureCollection of just the selected countries into
# `dir` — this *is* the file go-pmtiles' `--region=` reads. No
# union/dissolve needed: go-pmtiles treats a FeatureCollection's
# features as one combined MultiPolygon on its own (confirmed against
# its own source, pmtiles/region.go's UnmarshalRegion). Also returns a
# plain aggregate [south,west,north,east] (min/max over every ring
# coordinate, no geometry library) used only for the Mapterhorn
# chunk-intersection check below — approximate is fine there, the
# `--region=` file is what does the real precise selection.
def self.region_geojson_and_bbox(codes, dir)
# Still used by terrain (below) to compute the aggregate bbox Mapterhorn
# chunk intersection needs — offline.pmtiles no longer needs this at
# all, since each Geofabrik country file is already clipped to that
# country's real border (more accurate than this 110m polygon, not
# less), so there's no separate "--region=" precision step for it
# anymore.
def self.region_bbox(codes)
selected = region_features.select { |f| codes.include?(f["properties"]["iso_a2"]) }
raise BuildError, "no matching regions for #{codes.join(", ")}" if selected.empty?
path = File.join(dir, "region.geojson")
File.write(path, JSON.generate(type: "FeatureCollection", features: selected))
coords = selected.flat_map { |f| ring_coordinates(f["geometry"]) }
lons = coords.map { |lng, _lat| lng }
lats = coords.map { |_lng, lat| lat }
[path, [lats.min, lons.min, lats.max, lons.max]]
[lats.min, lons.min, lats.max, lons.max]
end
private_class_method :region_geojson_and_bbox
private_class_method :region_bbox
def self.ring_coordinates(geometry)
case geometry["type"]
@@ -140,21 +179,21 @@ module Maptool
# finishes — raises BuildError (the caller turns this into an error
# response) if nothing's selected yet, or one's already running.
#
# fetch_build_url exists as an injectable seam so tests can exercise the
# rest of this (temp path, go-pmtiles invocation, atomic rename) without
# a real network call — same reasoning test_helper.rb already gives for
# keeping /api/plugins route tests off the real Overpass API.
def self.build_offline(data_dir:, fetch_build_url: method(:latest_protomaps_build_url))
# download_extract exists as an injectable seam so tests can exercise
# the rest of this (scratch dir, osmium/planetiler invocation, atomic
# rename) without a real network call — same reasoning test_helper.rb
# already gives for keeping /api/plugins route tests off the real
# Overpass API.
def self.build_offline(data_dir:, download_extract: method(:download_extract))
codes = selected_regions(data_dir)
raise BuildError, "select at least one region first" if codes.empty?
begin_run!(OFFLINE_MUTEX, OFFLINE_STATUS)
Thread.new { run_job(OFFLINE_MUTEX, OFFLINE_STATUS) { extract_offline(codes, data_dir, fetch_build_url) } }
Thread.new { run_job(OFFLINE_MUTEX, OFFLINE_STATUS) { extract_offline(codes, data_dir, download_extract) } }
end
# fetch_manifest: same injectable-seam reasoning as fetch_build_url
# above, for the one Mapterhorn manifest lookup this needs instead of
# the Protomaps build index.
# fetch_manifest: same injectable-seam reasoning as download_extract
# above, for the one Mapterhorn manifest lookup this needs.
def self.build_terrain(data_dir:, fetch_manifest: method(:fetch_mapterhorn_manifest))
codes = selected_regions(data_dir)
raise BuildError, "select at least one region first" if codes.empty?
@@ -181,56 +220,103 @@ module Maptool
# --- offline.pmtiles ---
def self.extract_offline(codes, data_dir, fetch_build_url)
source = fetch_build_url.call
# One Geofabrik .osm.pbf per selected country (already clipped to that
# country's real border), osmium-merged into one file if more than one
# was selected (skipped entirely for a single country — nothing to
# merge), then tiled by Planetiler using its default OpenMapTiles
# profile straight to PMTiles — the same vector schema
# tiles.openfreemap.org's Liberty style already expects, so
# public/js/map.js's offlineStyle() can reuse ONLINE_STYLE_PATCH
# unchanged instead of a separate protomaps-themes-base palette.
def self.extract_offline(codes, data_dir, download_extract)
scratch_dir = Dir.mktmpdir("maptool-offline")
region_path, = region_geojson_and_bbox(codes, scratch_dir)
tmp_path = File.join(data_dir, "offline.pmtiles.tmp")
sources = codes.map do |code|
slug = GEOFABRIK_SLUGS.fetch(code) { raise BuildError, "no Geofabrik source known for region #{code}" }
dest = File.join(scratch_dir, "#{code}.osm.pbf")
last_modified = download_extract.call(geofabrik_url(slug), dest)
{ "code" => code, "slug" => slug, "last_modified" => last_modified, "path" => dest }
end
run_pmtiles!("extract", source, tmp_path, "--region=#{region_path}", "--maxzoom=15")
osm_path =
if sources.size == 1
sources.first["path"]
else
merged_path = File.join(scratch_dir, "merged.osm.pbf")
run_osmium!("merge", *sources.map { |s| s["path"] }, "-o", merged_path)
merged_path
end
tmp_path = File.join(data_dir, "offline.pmtiles.tmp")
run_planetiler!("--osm-path=#{osm_path}", "--output=#{tmp_path}")
File.rename(tmp_path, File.join(data_dir, "offline.pmtiles"))
File.write(offline_meta_path(data_dir), JSON.generate(source: File.basename(URI(source).path)))
File.write(offline_meta_path(data_dir), JSON.generate(sources: sources.map { |s| s.slice("code", "slug", "last_modified") }))
ensure
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
end
private_class_method :extract_offline
def self.latest_protomaps_build_url
response = Net::HTTP.get_response(URI(PROTOMAPS_BUILDS_INDEX))
raise BuildError, "could not reach #{PROTOMAPS_BUILDS_INDEX}" unless response.is_a?(Net::HTTPSuccess)
builds = JSON.parse(response.body)
raise BuildError, "protomaps build index was empty" if builds.empty?
"#{PROTOMAPS_BUILD_BASE}/#{builds.last.fetch("key")}"
rescue JSON::ParserError
raise BuildError, "protomaps build index was not valid JSON"
def self.geofabrik_url(slug)
"https://download.geofabrik.de/#{slug}-latest.osm.pbf"
end
private_class_method :latest_protomaps_build_url
private_class_method :geofabrik_url
# Sidecar recording which Protomaps build (a date-stamped filename, e.g.
# "20260910.pmtiles") the on-disk offline.pmtiles was actually extracted
# from — the file itself carries no such record. nil if never built (or
# built before this existed).
# Real network implementation of the download_extract seam above —
# streams the extract to dest and returns its Last-Modified header
# (free off the same GET, no separate HEAD needed) for the sidecar
# meta / update-check below.
def self.download_extract(url, dest)
uri = URI(url)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.get(uri) }
raise BuildError, "could not download #{url} (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess)
File.binwrite(dest, response.body)
response["last-modified"]
end
# Sidecar recording which Geofabrik sources (country code, slug, and
# that request's Last-Modified) the on-disk offline.pmtiles was
# actually built from — the file itself carries no such record. nil if
# never built (or built before this existed).
def self.offline_meta(data_dir)
read_json(offline_meta_path(data_dir))
end
# Compares the persisted build against the current latest without
# rebuilding anything — nil (not true/false) if there's nothing to
# compare yet (no local build) or the upstream index couldn't be
# reached, so the caller can tell "don't know" apart from "no".
def self.offline_update_available?(data_dir, fetch_build_url: method(:latest_protomaps_build_url))
# Compares each currently-selected country's live Geofabrik
# Last-Modified against what's recorded, without downloading anything
# — nil (not true/false) if there's nothing to compare yet (no local
# build, nothing selected) or a check couldn't be completed, so the
# caller can tell "don't know" apart from "no". Also naturally reports
# "yes" if the selection changed since the last build (a country now
# relevant that wasn't recorded, or vice versa) — same reasoning
# terrain_update_available? below already uses.
def self.offline_update_available?(data_dir, fetch_last_modified: method(:fetch_geofabrik_last_modified))
meta = offline_meta(data_dir)
return nil unless meta
latest = File.basename(URI(fetch_build_url.call).path)
latest != meta["source"]
codes = selected_regions(data_dir)
return nil if codes.empty?
current = codes.map do |code|
slug = GEOFABRIK_SLUGS[code]
return nil unless slug
{ "code" => code, "last_modified" => fetch_last_modified.call(geofabrik_url(slug)) }
end.sort_by { |s| s["code"] }
recorded = (meta["sources"] || []).map { |s| s.slice("code", "last_modified") }.sort_by { |s| s["code"] }
current != recorded
rescue BuildError
nil
end
def self.fetch_geofabrik_last_modified(url)
uri = URI(url)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.head(uri) }
raise BuildError, "could not reach #{url}" unless response.is_a?(Net::HTTPSuccess)
response["last-modified"]
end
def self.offline_meta_path(data_dir)
File.join(data_dir, "offline.pmtiles.meta.json")
end
@@ -258,8 +344,10 @@ module Maptool
raise BuildError, "mapterhorn manifest is missing #{MAPTERHORN_PLANET_NAME}" unless planet
scratch_dir = Dir.mktmpdir("maptool-terrain")
region_path, bbox = region_geojson_and_bbox(codes, scratch_dir)
south, west, north, east = bbox
south, west, north, east = region_bbox(codes)
region_path = File.join(scratch_dir, "region.geojson")
selected = region_features.select { |f| codes.include?(f["properties"]["iso_a2"]) }
File.write(region_path, JSON.generate(type: "FeatureCollection", features: selected))
chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) }
@@ -322,13 +410,10 @@ module Maptool
planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME }
return nil unless planet
Dir.mktmpdir do |dir|
_region_path, bbox = region_geojson_and_bbox(codes, dir)
south, west, north, east = bbox
chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) }
current = [planet, *chunks].map { |i| { "name" => i["name"], "md5sum" => i["md5sum"] } }.sort_by { |s| s["name"] }
current != (meta["sources"] || []).sort_by { |s| s["name"] }
end
south, west, north, east = region_bbox(codes)
chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) }
current = [planet, *chunks].map { |i| { "name" => i["name"], "md5sum" => i["md5sum"] } }.sort_by { |s| s["name"] }
current != (meta["sources"] || []).sort_by { |s| s["name"] }
rescue BuildError
nil
end
@@ -347,12 +432,24 @@ module Maptool
end
private_class_method :read_json
# ENV read lazily (not a load-time constant) so tests can point this at
# a fake script without a require-order dance.
# ENV read lazily (not load-time constants) so tests can point these at
# fake binaries without a require-order dance.
def self.pmtiles_bin
ENV.fetch("PMTILES_BIN", "pmtiles")
end
def self.osmium_bin
ENV.fetch("OSMIUM_BIN", "osmium")
end
def self.java_bin
ENV.fetch("JAVA_BIN", "java")
end
def self.planetiler_jar
ENV.fetch("PLANETILER_JAR", "/usr/local/lib/planetiler.jar")
end
def self.run_pmtiles!(*args)
_stdout, stderr, status = Open3.capture3(pmtiles_bin, *args)
raise BuildError, "pmtiles exited with status #{status.exitstatus}: #{stderr.strip}" unless status.success?
@@ -360,5 +457,21 @@ module Maptool
raise BuildError, "could not run '#{pmtiles_bin}' (#{e.message}) — is go-pmtiles installed and on PATH? See docs/development.adoc."
end
private_class_method :run_pmtiles!
def self.run_osmium!(*args)
_stdout, stderr, status = Open3.capture3(osmium_bin, *args)
raise BuildError, "osmium exited with status #{status.exitstatus}: #{stderr.strip}" unless status.success?
rescue Errno::ENOENT, Errno::EACCES => e
raise BuildError, "could not run '#{osmium_bin}' (#{e.message}) — is osmium-tool installed and on PATH? See docs/development.adoc."
end
private_class_method :run_osmium!
def self.run_planetiler!(*args)
_stdout, stderr, status = Open3.capture3(java_bin, "-jar", planetiler_jar, *args)
raise BuildError, "planetiler exited with status #{status.exitstatus}: #{stderr.strip}" unless status.success?
rescue Errno::ENOENT, Errno::EACCES => e
raise BuildError, "could not run '#{java_bin} -jar #{planetiler_jar}' (#{e.message}) — is Java and planetiler.jar installed? See docs/development.adoc."
end
private_class_method :run_planetiler!
end
end
+90 -112
View File
@@ -28,29 +28,46 @@ const CENTER = [17.1077, 48.1486];
const ZOOM = 13;
const ONLINE_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
// Recolors OpenFreeMap Liberty to the same NPS-inspired palette as
// OFFLINE_THEME below — warm cream ground, muted roads/boundaries — mapped
// onto Liberty's OpenMapTiles layer ids instead of protomaps' theme roles
// (the two schemas don't share layer names, so this is a separate
// hand-built table, not a shared object). Only the layers that dominate the
// map's overall color read are touched; labels, halos, rail, and minor
// overlays (cemetery/hospital/school/pitch/track shading, building
// outlines) are left at Liberty's own defaults — same "not a full
// recolor" scope as OFFLINE_THEME. Road casings have no protomaps-role
// equivalent to reuse (offline roads don't get a separate outline), so
// #c9a66b there is reasoned to fit the family rather than sourced from
// anywhere real.
// Recolors OpenFreeMap Liberty to a warm, NPS-Park-Tiles-inspired palette —
// cream ground, muted roads/boundaries — mapped onto Liberty's OpenMapTiles
// layer ids. Applied identically regardless of which PMTiles data actually
// backs the "openmaptiles" source (see offlineStyle() below): offline tiles
// are built by Planetiler's own OpenMapTiles profile (docs/development.adoc),
// the same schema Liberty already expects, so one patch serves both —
// no separate offline theme/palette to keep in sync. Only the layers that
// dominate the map's overall color read are touched; labels, halos, rail,
// and minor overlays (cemetery/hospital/school/pitch/track shading,
// building outlines) are left at Liberty's own defaults.
//
// wood/grass are deliberately left unpatched — Liberty's own green reads
// better than this app's earlier brown/khaki recolor, and OFFLINE_THEME's
// wood_a/scrub_a below were changed to match Liberty's green instead.
// wood/grass are deliberately left unpatched — Liberty's own green (a
// translucent landcover_wood/landcover_grass wash) already reads well over
// the patched cream background.
const ONLINE_STYLE_PATCH = {
background: { "background-color": "#f9f6ef" },
// Liberty washes every built-up area in a translucent gray-tan below
// z12 (landuse_residential, maxzoom 12 — gone by this app's default
// z13) that the offline schema has no equivalent of; protomaps just
// leaves that ground as plain background/earth. Matched to background
// here rather than left alone, so a zoomed-out view doesn't read as
// visibly dingier online than offline.
landuse_residential: { "fill-color": "#f9f6ef" },
landcover_sand: { "fill-color": "#f0e4c4" },
water: { "fill-color": "#a3cedf" },
park: { "fill-color": "#b3e378" },
park_outline: { "line-color": "#546619" },
building: { "fill-color": "#ded7cc" },
// fill-outline-color isn't touched by a plain fill-color patch — Liberty
// draws it separately, defaulting to a bright green that reads as a
// distinct border around named conservation areas (e.g. the Malé
// Karpaty CHKO). Matched to the fill itself, and park_outline's own
// dashed line hidden below, so a reserve blends in as ordinary forest —
// offline has no equivalent stroke at all (see park_a/park_b below),
// and the point was never to call reserves out as visibly different,
// on either side.
park: { "fill-color": "#b3e378", "fill-outline-color": "#b3e378" },
park_outline: { "line-opacity": 0 },
// fill-opacity: 0.5 matches the offline theme's own "buildings" layer —
// without it, Liberty's dense individual building footprints (each with
// its own outline) paint fully opaque and read much heavier/grayer than
// offline's softer, half-transparent fill over the same cream ground.
building: { "fill-color": "#ded7cc", "fill-opacity": 0.5 },
boundary_2: { "line-color": "#546619" },
boundary_3: { "line-color": "#546619" },
@@ -58,19 +75,30 @@ const ONLINE_STYLE_PATCH = {
// minor (residential/service/track), pedestrian — each repeated for its
// tunnel_/bridge_ counterpart so a road doesn't change color when it
// dips into a tunnel or crosses a bridge. Rail is left alone.
road_motorway: { "line-color": "#fdefbe" },
road_trunk_primary: { "line-color": "#fdefbe" },
road_secondary_tertiary: { "line-color": "#fef7dd" },
road_link: { "line-color": "#fef7dd" },
road_motorway_link: { "line-color": "#fef7dd" },
//
// line-width below is a flat number, not Liberty's own zoom-interpolated
// expression — picked to match the offline theme's actual rendered width
// at this app's default zoom (13; verified live via queryRenderedFeatures
// on both sides), since without it Liberty's own curve draws surface
// roads roughly twice as thick as offline at street level, reading as a
// much bolder road network online.
// ponytail: tuned for z13 only, not a matching curve across all zooms —
// will drift back apart at very different zoom levels. Upgrade path:
// sample offline's width at a few more zooms and replace with a matching
// ["interpolate", ["linear"], ["zoom"], ...] expression.
road_motorway: { "line-color": "#fdefbe", "line-width": 2.26 },
road_trunk_primary: { "line-color": "#fdefbe", "line-width": 2.26 },
road_secondary_tertiary: { "line-color": "#fef7dd", "line-width": 1.87 },
road_link: { "line-color": "#fef7dd", "line-width": 1 },
road_motorway_link: { "line-color": "#fef7dd", "line-width": 1 },
road_minor: { "line-color": "#fffdf8" },
road_service_track: { "line-color": "#fffdf8" },
road_path_pedestrian: { "line-color": "#e9e2d8" },
road_motorway_casing: { "line-color": "#c9a66b" },
road_trunk_primary_casing: { "line-color": "#c9a66b" },
road_secondary_tertiary_casing: { "line-color": "#c9a66b" },
road_link_casing: { "line-color": "#c9a66b" },
road_motorway_link_casing: { "line-color": "#c9a66b" },
road_motorway_casing: { "line-color": "#c9a66b", "line-width": 1.48 },
road_trunk_primary_casing: { "line-color": "#c9a66b", "line-width": 1.48 },
road_secondary_tertiary_casing: { "line-color": "#c9a66b", "line-width": 1 },
road_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 },
road_motorway_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 },
road_minor_casing: { "line-color": "#c9a66b" },
road_service_track_casing: { "line-color": "#c9a66b" },
@@ -161,95 +189,45 @@ async function loadOnlineStyle() {
const ONLINE_STYLE = await loadOnlineStyle();
// A vendored copy of the same Liberty style ONLINE_STYLE fetches live
// (public/vendor/liberty-style/liberty.json — a point-in-time snapshot, not
// auto-updated) that offlineStyle() below patches+repoints the exact same
// way. Offline mode can't depend on ONLINE_STYLE_URL actually being
// reachable — that fetch failing/timing out is the normal case in the
// field, not an edge case — so this is a local, same-origin fetch instead
// (fast and always available, since it's served by this app itself).
const OFFLINE_STYLE_TEMPLATE = await fetch("/vendor/liberty-style/liberty.json").then((response) => response.json());
const protocol = new pmtiles.Protocol();
addProtocol("pmtiles", protocol.tile);
// Nudged 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. Not their actual map: a handful of
// values below (marked) 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 their four styles served from a plain,
// unauthenticated URL; "Standard"/"Slate"/"Light" resolve through 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).
// The rest are reasoned to match that same family (warm cream ground, olive
// greens, muted blue) rather than sourced. Not a full recolor: labels,
// casings, and roles with no clean real-color anchor are left at theme
// defaults.
// Builds the offline style from OFFLINE_STYLE_TEMPLATE — the identical
// Liberty JSON + ONLINE_STYLE_PATCH recolor as the online map, just
// repointed at a local PMTiles source instead of tiles.openfreemap.org.
// This works because data/offline.pmtiles is now built by Planetiler's own
// OpenMapTiles profile (see docs/development.adoc), the same vector schema
// Liberty expects — before that, offline tiles used a different schema
// (protomaps' own basemaps) needing a completely separate hand-tuned
// theme (protomaps-themes-base + OFFLINE_THEME) that could never be made
// to match ONLINE_STYLE_PATCH's look exactly, no matter how much either
// side got tuned.
//
// Recreating the color choices is legitimate — that's not the kind of thing
// a data license restricts (see docs/architecture.adoc's "Offline maps"
// section) — what stays off-limits is their actual tile *service*: its
// OSM/terrain layers are licensed through Mapbox Atlas, a commercial,
// non-sublicensable data service scoped to NPS.
const OFFLINE_THEME = {
...protomaps_themes_base.namedTheme("light"),
background: "#f9f6ef", // real: Park Tiles' own background, hsl(40, 37%, 97%)
earth: "#f9f6ef",
water: "#a3cedf",
// Matches OpenFreeMap Liberty's own wood/grass green (its landcover_wood
// hsla(98,61%,72%,0.7) and landcover_grass rgba(176,213,154,1) at 0.3
// opacity, blended over the cream background) — the online map's actual
// look whenever ONLINE_STYLE_PATCH's brown/khaki recolor fails to apply
// (see loadOnlineStyle's fetch fallback) turned out to be the look this
// app's users actually want, so offline was changed to match it instead
// of chasing the patch's own reliability.
wood_a: "#c3e8ab",
wood_b: "#a8d888",
scrub_a: "#dcecc9",
scrub_b: "#cfe3b8",
// Close to wood_b, not the more saturated Park Tiles green: protomaps'
// theme routes "forest"/"nature_reserve"/"protected_area" (e.g. the Malé
// Karpaty CHKO boundary) through this same park_a/park_b pair as actual
// named parks — Liberty's online style doesn't make that reserve-boundary
// distinction at all, so a visibly different green here reads as an
// offline-only blotch rather than a deliberate distinction.
park_a: "#c3e8ab",
park_b: "#a8d888",
sand: "#f0e4c4",
beach: "#f0e4c4",
buildings: "#ded7cc",
pedestrian: "#e9e2d8",
highway: "#fdefbe", // real: Park Tiles' primary/highway road color
major: "#fef7dd", // real: Park Tiles' secondary road color
// "minor" (singular) is never actually read by protomaps' own paint
// expressions — only the zoom-interpolated minor_a/minor_b pair is, so a
// plain "minor" key here was dead: ordinary streets were silently
// falling back to the base theme's default pale yellow instead of this
// app's cream.
minor_a: "#fffdf8",
minor_b: "#fffdf8",
link: "#fef7dd",
// Distinct brown, not cream: protomaps' "other"/"path" kind is what a
// camper actually needs called out — unpaved tracks and footpaths,
// separate from paved minor_road/service (minor_service, left at the
// base theme's default since it's real driveways/parking aisles, not
// unpaved). Already rendered dashed by the theme itself; this just picks
// a real "dirt" tone instead of unstyled white.
other: "#c9a66b",
boundaries: "#546619", // real: Park Tiles' park-boundary line color
};
// glyphs/sprite point at vendored local copies (public/vendor/fonts,
// public/vendor/liberty-sprite) rather than tiles.openfreemap.org's —
// glyphs are Latin-only (see docs/architecture.adoc: labels in other
// scripts won't render offline), sprite is a one-time snapshot like
// OFFLINE_STYLE_TEMPLATE itself. ne2_shaded (Liberty's low-zoom shaded-
// relief backdrop, z0-6 only) is left pointing at its remote URL
// unchanged — offline usage is regional, not whole-earth zoomed out, so
// this only ever silently fails to load at a zoom level offline users
// aren't in, the same graceful-degradation story as this app's other
// online-only extras (Wikipedia, live radar).
function offlineStyle() {
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
return {
version: 8,
glyphs: `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`,
sources: {
offline: {
type: "vector",
url: `pmtiles://${pmtilesUrl}`,
attribution: "&copy; OpenStreetMap contributors",
},
},
// lang: "en" pulls in labels_layers (see docs/architecture.adoc) — only
// the Latin-script glyph ranges are vendored, so labels using other
// scripts won't render offline.
layers: protomaps_themes_base.layers("offline", OFFLINE_THEME, { lang: "en" }),
};
const style = patchOnlineStyle(structuredClone(OFFLINE_STYLE_TEMPLATE));
style.sources.openmaptiles.url = `pmtiles://${window.location.origin}/tiles/offline.pmtiles`;
style.glyphs = `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`;
style.sprite = `${window.location.origin}/vendor/liberty-sprite/ofm`;
return style;
}
// A sidebar icon that opens a full-height side panel (like the GPX one)
+8 -8
View File
@@ -37,13 +37,13 @@ function flagIconUrl(code) {
return `/vendor/flag-icons/1x1/${code.toLowerCase()}.svg`;
}
// meta.source is a Protomaps build filename like "20260910.pmtiles" — the
// leading 8 digits are its build date.
// meta.sources is one entry per country the current build covers (see
// lib/maptool/pmtiles_builder.rb) — listing the codes is more useful here
// than a count, since they're exactly what RegionPicker's checklist above
// already shows.
function formatOfflineMeta(meta) {
if (!meta) return "";
const match = meta.source.match(/^(\d{4})(\d{2})(\d{2})/);
const date = match ? `${match[1]}-${match[2]}-${match[3]}` : meta.source;
return ` · Protomaps ${date}`;
return ` · ${meta.sources.map((s) => s.code).join(", ")}`;
}
function formatTerrainMeta(meta) {
@@ -172,9 +172,9 @@ class DataFileRow {
}
// Separate from _refreshStatus's own fetch (and only called once, right
// after — not polled): this one reaches an external service (Protomaps'
// build index or Mapterhorn's manifest, see
// lib/maptool/pmtiles_builder.rb), so it's kept off the hot status path
// after — not polled): this one reaches an external service (Geofabrik
// or Mapterhorn's manifest, see lib/maptool/pmtiles_builder.rb), so it's
// kept off the hot status path
// and just silently skipped if it fails, same as everywhere else this
// app degrades gracefully when an optional network check doesn't answer.
async _checkUpdate() {
-111
View File
@@ -1,111 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Offline theme preview</title>
<link rel="stylesheet" href="/vendor/maplibre-gl/maplibre-gl.css">
<style>
body { margin: 0; font-family: sans-serif; background: #222; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.cell { position: relative; height: 90vh; }
.cell .map, .cell iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.cell .label {
position: absolute; top: 4px; left: 4px; z-index: 1;
background: rgba(0,0,0,.6); color: #fff; padding: 2px 8px;
font-size: 13px; border-radius: 3px;
}
</style>
</head>
<body>
<div class="grid">
<div class="cell">
<div class="label">light (bare base theme)</div>
<div class="map" id="light-map"></div>
</div>
<div class="cell">
<div class="label">offline (current app)</div>
<iframe id="offline-frame" src="/"></iframe>
</div>
<div class="cell">
<div class="label">online (current app)</div>
<iframe id="online-frame" src="/"></iframe>
</div>
</div>
<script src="/vendor/pmtiles/pmtiles.js"></script>
<script src="/vendor/protomaps-themes-base/protomaps-themes-base.js"></script>
<script type="module">
import { Map as MaplibreMap, addProtocol } from "/vendor/maplibre-gl/maplibre-gl.mjs";
const protocol = new pmtiles.Protocol();
addProtocol("pmtiles", protocol.tile);
const CENTER = [17.1077, 48.1486];
const ZOOM = 13;
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
const lightMap = new MaplibreMap({
container: "light-map",
center: CENTER,
zoom: ZOOM,
style: {
version: 8,
glyphs: `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`,
sources: {
offline: { type: "vector", url: `pmtiles://${pmtilesUrl}` },
},
layers: protomaps_themes_base.layers("offline", protomaps_themes_base.namedTheme("light"), { lang: "en" }),
},
});
// "offline" frame needs nothing: the app already defaults to its offline
// style whenever /api/offline-tiles reports data present (see map.js).
// "online" frame: click its own real Online button, same button a user
// would click — reuses the app's actual ONLINE_STYLE (patched Liberty)
// instead of re-fetching/re-patching a second copy here.
const offlineFrame = document.getElementById("offline-frame");
const onlineFrame = document.getElementById("online-frame");
const clickOnline = () => {
const button = onlineFrame.contentDocument?.querySelector('.basemap-panel__option[data-style="online"]');
if (button) button.click();
};
onlineFrame.addEventListener("load", () => {
clickOnline();
setTimeout(clickOnline, 1000); // retry once in case map.js was still mid-init
});
// Keep all three views looking at the same spot. The two iframes are the
// real app (separate documents, own module scope — nothing to reach into
// directly), but map.js already syncs its map to the URL hash (`hash:
// "map"`, format #map=zoom/lat/lon) for shareable links. Reusing that: poll
// each source's position, and whichever moved gets pushed to the other
// two — for a frame, by writing its location.hash, which fires its own
// hashchange and lets its Hash control jumpTo for us.
const HASH_RE = /map=([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)/;
function frameHash(frame) {
const match = HASH_RE.exec(frame.contentWindow?.location.hash ?? "");
return match ? { zoom: +match[1], lat: +match[2], lon: +match[3] } : null;
}
function lightPos() {
const c = lightMap.getCenter();
return { zoom: lightMap.getZoom(), lat: c.lat, lon: c.lng };
}
function samePos(a, b) {
return Math.abs(a.zoom - b.zoom) < 0.005 && Math.abs(a.lat - b.lat) < 1e-4 && Math.abs(a.lon - b.lon) < 1e-4;
}
let current = { zoom: ZOOM, lat: CENTER[1], lon: CENTER[0] };
setInterval(() => {
const sources = { light: lightPos(), offline: frameHash(offlineFrame), online: frameHash(onlineFrame) };
const leader = Object.keys(sources).find((name) => sources[name] && !samePos(sources[name], current));
if (!leader) return;
current = sources[leader];
if (leader !== "light") lightMap.jumpTo({ center: [current.lon, current.lat], zoom: current.zoom });
if (leader !== "offline" && offlineFrame.contentWindow) {
offlineFrame.contentWindow.location.hash = `map=${current.zoom}/${current.lat}/${current.lon}`;
}
if (leader !== "online" && onlineFrame.contentWindow) {
onlineFrame.contentWindow.location.hash = `map=${current.zoom}/${current.lat}/${current.lon}`;
}
}, 150);
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+1
View File
@@ -0,0 +1 @@
{"version":8,"sources":{"ne2_shaded":{"maxzoom":6,"tileSize":256,"tiles":["https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png"],"type":"raster"},"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#f8f4f0"}},{"id":"natural_earth","type":"raster","source":"ne2_shaded","maxzoom":7,"paint":{"raster-opacity":["interpolate",["exponential",1.5],["zoom"],0,0.6,6,0.1]}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#d8e8c8","fill-opacity":0.7,"fill-outline-color":"rgba(95, 208, 100, 1)"}},{"id":"park_outline","type":"line","source":"openmaptiles","source-layer":"park","paint":{"line-color":"rgba(228, 241, 215, 1)","line-dasharray":[1,1.5]}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":["interpolate",["linear"],["zoom"],9,"hsla(0,3%,85%,0.84)",12,"hsla(35,57%,88%,0.49)"]}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"hsla(98,61%,72%,0.7)","fill-opacity":0.4}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"rgba(176, 213, 154, 1)","fill-opacity":0.3}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"rgba(224, 236, 236, 1)","fill-opacity":0.8}},{"id":"landcover_wetland","type":"fill","source":"openmaptiles","source-layer":"landcover","minzoom":12,"filter":["==",["get","class"],"wetland"],"paint":{"fill-antialias":true,"fill-opacity":0.8,"fill-pattern":"wetland_bg_11","fill-translate-anchor":"map"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#DEE3CD"}},{"id":"landuse_track","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"track"],"paint":{"fill-color":"#DEE3CD"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"hsl(75,37%,81%)"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#fde"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"rgb(236,238,204)"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#a0c8f0","line-dasharray":[3,3],"line-gap-width":["interpolate",["linear"],["zoom"],12,0,20,6],"line-opacity":1,"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a0c8f0","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a0c8f0","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"rgb(158,189,255)"}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"rgba(247, 239, 195, 1)"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"rgba(229, 228, 224, 1)","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#f0ede9","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"Line truncated
@@ -1,16 +0,0 @@
"use strict";var protomaps_themes_base=(()=>{var f=Object.defineProperty;var D=Object.getOwnPropertyDescriptor;var T=Object.getOwnPropertyNames,x=Object.getOwnPropertySymbols;var j=Object.prototype.hasOwnProperty,$=Object.prototype.propertyIsEnumerable;var z=(a,e,i)=>e in a?f(a,e,{enumerable:!0,configurable:!0,writable:!0,value:i}):a[e]=i,b=(a,e)=>{for(var i in e||(e={}))j.call(e,i)&&z(a,i,e[i]);if(x)for(var i of x(e))$.call(e,i)&&z(a,i,e[i]);return a};var l=(a,e)=>f(a,"name",{value:e,configurable:!0});var C=(a,e)=>{for(var i in e)f(a,i,{get:e[i],enumerable:!0})},P=(a,e,i,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of T(e))!j.call(a,r)&&r!==i&&f(a,r,{get:()=>e[r],enumerable:!(n=D(e,r))||n.enumerable});return a};var A=a=>P(f({},"__esModule",{value:!0}),a);var W={};C(W,{default:()=>S,labels:()=>I,labelsWithCustomTheme:()=>K,language_script_pairs:()=>m,layers:()=>B,layersWithCustomTheme:()=>V,layersWithPartialCustomTheme:()=>F,namedTheme:()=>H,noLabels:()=>E,noLabelsWithCustomTheme:()=>G});function t(a,e){let i="script";return a==="name"?i="script":a==="name2"?i="script2":a==="name3"&&(i="script3"),[["coalesce",["get",`pgf:${a}`],["get",a]],{"text-font":["case",["==",["get",i],"Devanagari"],["literal",["Noto Sans Devanagari Regular v1"]],["literal",[e||"Noto Sans Regular"]]]}]}l(t,"get_name_block");function c(a,e,i){let n="name";return i==="name"?n="":i==="name2"?n="2":i==="name3"&&(n="3"),e==="Latin"?["has",`script${n}`]:a==="ja"?["all",["!=",["get",`script${n}`],"Han"],["!=",["get",`script${n}`],"Hiragana"],["!=",["get",`script${n}`],"Katakana"],["!=",["get",`script${n}`],"Mixed-Japanese"]]:["!=",["get",`script${n}`],e]}l(c,"is_not_in_target_script");function o(a){return a==="Devanagari"?{"text-font":["literal",["Noto Sans Devanagari Regular v1"]]}:{}}l(o,"get_font_formatting");function v(a){let e=m.find(i=>i.lang===a);return e===void 0?"Latin":e.script}l(v,"get_default_script");function L(a,e){let i=e||v(a),n;return i==="Devanagari"?n="pgf:":n="",["format",["coalesce",["get",`${n}name:${a}`],["get","name:en"]],o(i)]}l(L,"get_country_name");function s(a,e,i){let n=e||v(a),r;return n==="Devanagari"?r="pgf:":r="",["case",["all",["any",["has","name"],["has","pgf:name"]],["!",["any",["has","name2"],["has","pgf:name2"]]],["!",["any",["has","name3"],["has","pgf:name3"]]]],["case",c(a,n,"name"),["case",["any",["is-supported-script",["get","name"]],["has","pgf:name"]],["format",["coalesce",["get",`${r}name:${a}`],["get","name:en"]],o(n),`
`,{},["case",["all",["!",["has",`${r}name:${a}`]],["has","name:en"],["!",["has","script"]]],"",["coalesce",["get","pgf:name"],["get","name"]]],{"text-font":["case",["==",["get","script"],"Devanagari"],["literal",["Noto Sans Devanagari Regular v1"]],["literal",[i||"Noto Sans Regular"]]]}],["get","name:en"]],["format",["coalesce",["get",`${r}name:${a}`],["get","pgf:name"],["get","name"]],o(n)]],["all",["any",["has","name"],["has","pgf:name"]],["any",["has","name2"],["has","pgf:name2"]],["!",["any",["has","name3"],["has","pgf:name3"]]]],["case",["all",c(a,n,"name"),c(a,n,"name2")],["format",["get",`${r}name:${a}`],o(n),`
`,{},...t("name"),`
`,{},...t("name2")],["case",c(a,n,"name2"),["format",["coalesce",["get",`${r}name:${a}`],["get","pgf:name"],["get","name"]],o(n),`
`,{},...t("name2")],["format",["coalesce",["get",`${r}name:${a}`],["get","pgf:name2"],["get","name2"]],o(n),`
`,{},...t("name")]]],["case",["all",c(a,n,"name"),c(a,n,"name2"),c(a,n,"name3")],["format",["get",`${r}name:${a}`],o(n),`
`,{},...t("name"),`
`,{},...t("name2"),`
`,{},...t("name3")],["case",["!",c(a,n,"name")],["format",["coalesce",["get",`${r}name:${a}`],["get","pgf:name"],["get","name"]],o(n),`
`,{},...t("name2"),`
`,{},...t("name3")],["!",c(a,n,"name2")],["format",["coalesce",["get",`${r}name:${a}`],["get","pgf:name2"],["get","name2"]],o(n),`
`,{},...t("name"),`
`,{},...t("name3")],["format",["coalesce",["get",`${r}name:${a}`],["get","pgf:name3"],["get","name3"]],o(n),`
`,{},...t("name"),`
`,{},...t("name2")]]]]}l(s,"get_multiline_name");var m=[{lang:"ar",full_name:"Arabic",script:"Arabic"},{lang:"cs",full_name:"Czech",script:"Latin"},{lang:"bg",full_name:"Bulgarian",script:"Cyrillic"},{lang:"da",full_name:"Danish",script:"Latin"},{lang:"de",full_name:"German",script:"Latin"},{lang:"el",full_name:"Greek",script:"Greek"},{lang:"en",full_name:"English",script:"Latin"},{lang:"es",full_name:"Spanish",script:"Latin"},{lang:"et",full_name:"Estonian",script:"Latin"},{lang:"fa",full_name:"Persian",script:"Arabic"},{lang:"fi",full_name:"Finnish",script:"Latin"},{lang:"fr",full_name:"French",script:"Latin"},{lang:"ga",full_name:"Irish",script:"Latin"},{lang:"he",full_name:"Hebrew",script:"Hebrew"},{lang:"hi",full_name:"Hindi",script:"Devanagari"},{lang:"hr",full_name:"Croatian",script:"Latin"},{lang:"hu",full_name:"Hungarian",script:"Latin"},{lang:"id",full_name:"Indonesian",script:"Latin"},{lang:"it",full_name:"Italian",script:"Latin"},{lang:"ja",full_name:"Japanese",script:""},{lang:"ko",full_name:"Korean",script:"Hangul"},{lang:"lt",full_name:"Lithuanian",script:"Latin"},{lang:"lv",full_name:"Latvian",script:"Latin"},{lang:"ne",full_name:"Nepali",script:"Devanagari"},{lang:"nl",full_name:"Dutch",script:"Latin"},{lang:"no",full_name:"Norwegian",script:"Latin"},{lang:"mr",full_name:"Marathi",script:"Devanagari"},{lang:"mt",full_name:"Maltese",script:"Latin"},{lang:"pl",full_name:"Polish",script:"Latin"},{lang:"pt",full_name:"Portuguese",script:"Latin"},{lang:"ro",full_name:"Romanian",script:"Latin"},{lang:"ru",full_name:"Russian",script:"Cyrillic"},{lang:"sk",full_name:"Slovak",script:"Latin"},{lang:"sl",full_name:"Slovenian",script:"Latin"},{lang:"sv",full_name:"Swedish",script:"Latin"},{lang:"tr",full_name:"Turkish",script:"Latin"},{lang:"uk",full_name:"Ukrainian",script:"Cyrillic"},{lang:"ur",full_name:"Urdu",script:"Arabic"},{lang:"vi",full_name:"Vietnamese",script:"Latin"},{lang:"zh-Hans",full_name:"Chinese (Simplified)",script:"Han"},{lang:"zh-Hant",full_name:"Chinese (Traditional)",script:"Han"}];function _(a,e){return[{id:"background",type:"background",paint:{"background-color":e.background}},{id:"earth",type:"fill",filter:["==","$type","Polygon"],source:a,"source-layer":"earth",paint:{"fill-color":e.earth}},...e.landcover?[{id:"landcover",type:"fill",source:a,"source-layer":"landcover",paint:{"fill-color":["match",["get","kind"],"grassland",e.landcover.grassland,"barren",e.landcover.barren,"urban_area",e.landcover.urban_area,"farmland",e.landcover.farmland,"glacier",e.landcover.glacier,"scrub",e.landcover.scrub,e.landcover.forest],"fill-opacity":["interpolate",["linear"],["zoom"],5,1,7,0]}}]:[],{id:"landuse_park",type:"fill",source:a,"source-layer":"landuse",filter:["in","kind","national_park","park","cemetery","protected_area","nature_reserve","forest","golf_course","wood","nature_reserve","forest","scrub","grassland","grass","military","naval_base","airfield"],paint:{"fill-opacity":["interpolate",["linear"],["zoom"],6,0,11,1],"fill-color":["case",["in",["get","kind"],["literal",["national_park","park","cemetery","protected_area","nature_reserve","forest","golf_course"]]],e.park_b,["in",["get","kind"],["literal",["wood","nature_reserve","forest"]]],e.wood_b,["in",["get","kind"],["literal",["scrub","grassland","grass"]]],e.scrub_b,["in",["get","kind"],["literal",["glacier"]]],e.glacier,["in",["get","kind"],["literal",["sand"]]],e.sand,["in",["get","kind"],["literal",["military","naval_base","airfield"]]],e.zoo,e.earth]}},{id:"landuse_urban_green",type:"fill",source:a,"source-layer":"landuse",filter:["in","kind","allotments","village_green","playground"],paint:{"fill-color":e.park_b,"fill-opacity":.7}},{id:"landuse_hospital",type:"fill",source:a,"source-layer":"landuse",filter:["==","kind","hospital"],paint:{"fill-color":e.hospital}},{id:"landuse_industrial",type:"fill",source:a,"source-layer":"landuse",filter:["==","kind","industrial"],paint:{"fill-color":e.industrial}},{id:"landuse_school",type:"fill",source:a,"source-layer":"landuse",filter:["in","kind","school","university","college"],paint:{"fill-color":e.school}},{id:"landuse_beach",type:"fill",source:a,"source-layer":"landuse",filter:["in","kind","beach"],paint:{"fill-color":e.beach}},{id:"landuse_zoo",type:"fill",source:a,"source-layer":"landuse",filter:["in","kind","zoo"],paint:{"fill-color":e.zoo}},{id:"landuse_aerodrome",type:"fill",source:a,"source-layer":"landuse",filter:["in","kind","aerodrome"],paint:{"fill-color":e.aerodrome}},{id:"roads_runway",type:"line",source:a,"source-layer":"roads",filter:["==","kind_detail","runway"],paint:{"line-color":e.runway,"line-width":["interpolate",["exponential",1.6],["zoom"],10,0,12,4,18,30]}},{id:"roads_taxiway",type:"line",source:a,"source-layer":"roads",minzoom:13,filter:["==","kind_detail","taxiway"],paint:{"line-color":e.runway,"line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,15,6]}},{id:"landuse_runway",type:"fill",source:a,"source-layer":"landuse",filter:["any",["in Line truncated
//# sourceMappingURL=protomaps-themes-base.js.map
+2 -2
View File
@@ -402,13 +402,13 @@ class AppTest < Minitest::Test
assert_equal 409, response.status
end
# fetch_build_url blocks on a Queue, called directly (not through the
# download_extract blocks on a Queue, called directly (not through the
# route, which has no way to inject a fake) so the build is
# deterministically still "running" when the route call below happens.
def test_offline_tiles_build_is_409_when_one_is_already_running
Maptool::PmtilesBuilder.set_selected_regions(Maptool::App::DATA_DIR, ["SK"])
gate = Queue.new
thread = Maptool::PmtilesBuilder.build_offline(data_dir: Maptool::App::DATA_DIR, fetch_build_url: -> { gate.pop })
thread = Maptool::PmtilesBuilder.build_offline(data_dir: Maptool::App::DATA_DIR, download_extract: ->(_url, _dest) { gate.pop })
response = request.post("/api/offline-tiles/build", **logged_in_env)
assert_equal 409, response.status
+106 -35
View File
@@ -3,14 +3,15 @@ require "maptool/pmtiles_builder"
require "tmpdir"
require "fileutils"
# Network calls (the protomaps build index, the mapterhorn manifest) are
# Network calls (Geofabrik downloads/HEADs, the mapterhorn manifest) are
# never exercised here — same "keep this offline/deterministic" reasoning
# test_helper.rb already gives for /api/plugins staying off the real
# Overpass API — build_offline/build_terrain take fetch_build_url/
# fetch_manifest precisely so tests can swap those out. A fake `pmtiles`
# CLI (PMTILES_BIN) stands in for the real go-pmtiles binary, logging each
# invocation's subcommand + first arg to PMTILES_LOG so tests can assert on
# what actually got called (e.g. "was merge invoked, and with what").
# Overpass API — build_offline/build_terrain take download_extract/
# fetch_manifest precisely so tests can swap those out. Fake `pmtiles`,
# `osmium`, and `java` binaries (PMTILES_BIN/OSMIUM_BIN/JAVA_BIN) stand in
# for the real tools, logging each invocation's subcommand/first arg to
# PMTILES_LOG so tests can assert on what actually got called (e.g. "was
# merge invoked, and with what").
#
# Country codes below are real ones from the vendored
# lib/maptool/data/europe_countries.geojson (Slovakia, Iceland, Spain) —
@@ -46,8 +47,45 @@ class PmtilesBuilderTest < Minitest::Test
File.write(@pmtiles_fail, "#!/bin/sh\necho 'boom' >&2\nexit 1\n")
File.chmod(0o755, @pmtiles_fail)
# osmium merge PBF1 PBF2 ... -o OUTPUT — output is always "-o"'s next
# arg, unlike pmtiles' varying-position output above.
@osmium_ok = File.join(@bin_dir, "osmium-ok")
File.write(@osmium_ok, <<~SH)
#!/bin/sh
echo "osmium $1" >> "$PMTILES_LOG"
prev=""
for arg; do
if [ "$prev" = "-o" ]; then echo fake > "$arg"; fi
prev="$arg"
done
SH
File.chmod(0o755, @osmium_ok)
# Stands in for `java -jar planetiler.jar --osm-path=... --output=OUT`
# — PLANETILER_JAR itself is irrelevant here (this fake ignores -jar's
# argument entirely), only JAVA_BIN needs pointing at it.
@java_ok = File.join(@bin_dir, "java-ok")
File.write(@java_ok, <<~SH)
#!/bin/sh
echo "planetiler ${3%%=*}" >> "$PMTILES_LOG"
for arg; do
case "$arg" in
--output=*) echo fake > "${arg#--output=}" ;;
esac
done
SH
File.chmod(0o755, @java_ok)
@java_fail = File.join(@bin_dir, "java-fail")
File.write(@java_fail, "#!/bin/sh\necho 'boom' >&2\nexit 1\n")
File.chmod(0o755, @java_fail)
@original_pmtiles_bin = ENV["PMTILES_BIN"]
@original_osmium_bin = ENV["OSMIUM_BIN"]
@original_java_bin = ENV["JAVA_BIN"]
ENV["PMTILES_LOG"] = @log
ENV["OSMIUM_BIN"] = @osmium_ok
ENV["JAVA_BIN"] = @java_ok
Maptool::PmtilesBuilder.reset!
end
@@ -55,10 +93,22 @@ class PmtilesBuilderTest < Minitest::Test
FileUtils.remove_entry(@data_dir)
FileUtils.remove_entry(@bin_dir)
ENV["PMTILES_BIN"] = @original_pmtiles_bin
ENV["OSMIUM_BIN"] = @original_osmium_bin
ENV["JAVA_BIN"] = @original_java_bin
ENV.delete("PMTILES_LOG")
Maptool::PmtilesBuilder.reset!
end
# download_extract stub: writes a marker file (same "fake" content the
# pmtiles/osmium/java fixtures use) instead of hitting Geofabrik, and
# returns a fixed Last-Modified so sidecar-meta assertions are stable.
def fake_download(last_modified: "Wed, 10 Sep 2026 08:00:01 GMT")
->(_url, dest) {
File.write(dest, "fake")
last_modified
}
end
def log_lines
File.exist?(@log) ? File.readlines(@log, chomp: true) : []
end
@@ -107,21 +157,36 @@ class PmtilesBuilderTest < Minitest::Test
def test_build_offline_raises_with_no_selection
assert_raises(Maptool::PmtilesBuilder::BuildError) do
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "unused" })
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
end
end
def test_build_offline_writes_the_file_and_reports_done
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join
assert_path_exists File.join(@data_dir, "offline.pmtiles")
refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp")
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
assert_equal ["extract https://example.com/fake.pmtiles"], log_lines
assert_equal({ "source" => "fake.pmtiles" }, Maptool::PmtilesBuilder.offline_meta(@data_dir))
assert_equal ["planetiler --osm-path"], log_lines
assert_equal(
{ "sources" => [{ "code" => "SK", "slug" => "europe/slovakia", "last_modified" => "Wed, 10 Sep 2026 08:00:01 GMT" }] },
Maptool::PmtilesBuilder.offline_meta(@data_dir),
)
end
# Two countries selected — osmium merge should run before planetiler,
# each downloaded source recorded in the sidecar meta.
def test_build_offline_merges_multiple_selected_countries
select_regions("SK", "AT")
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
assert_equal ["osmium merge", "planetiler --osm-path"], log_lines
codes = Maptool::PmtilesBuilder.offline_meta(@data_dir)["sources"].map { |s| s["code"] }
assert_equal %w[SK AT], codes
end
def test_offline_meta_is_nil_before_any_build
@@ -129,38 +194,45 @@ class PmtilesBuilderTest < Minitest::Test
end
def test_offline_update_available_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_last_modified: ->(_url) { "unused" })
end
def test_offline_update_available_is_false_when_unchanged
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }).join
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
refute Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
refute Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_last_modified: ->(_url) { "Wed, 10 Sep 2026 08:00:01 GMT" })
end
def test_offline_update_available_is_true_when_a_newer_build_exists
def test_offline_update_available_is_true_when_a_newer_extract_exists
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/old.pmtiles" }).join
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
assert Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: -> { "https://example.com/new.pmtiles" })
assert Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_last_modified: ->(_url) { "Thu, 11 Sep 2026 08:00:01 GMT" })
end
def test_offline_update_available_is_nil_when_the_index_is_unreachable
# Same recorded source, but the selection has since grown — recorded
# sources no longer match what the current selection would need.
def test_offline_update_available_is_true_when_the_selection_grew
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }).join
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
fetch_build_url = -> { raise Maptool::PmtilesBuilder::BuildError, "could not reach index" }
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_build_url: fetch_build_url)
select_regions("SK", "AT")
assert Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_last_modified: ->(_url) { "Wed, 10 Sep 2026 08:00:01 GMT" })
end
def test_offline_update_available_is_nil_when_geofabrik_is_unreachable
select_regions("SK")
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
fetch_last_modified = ->(_url) { raise Maptool::PmtilesBuilder::BuildError, "could not reach geofabrik" }
assert_nil Maptool::PmtilesBuilder.offline_update_available?(@data_dir, fetch_last_modified: fetch_last_modified)
end
def test_build_offline_reports_an_error_status_on_non_zero_exit
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_fail
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
ENV["JAVA_BIN"] = @java_fail
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join
refute_path_exists File.join(@data_dir, "offline.pmtiles")
@@ -169,31 +241,30 @@ class PmtilesBuilderTest < Minitest::Test
assert_match(/exited with status/, status[:message])
end
def test_build_offline_reports_a_clear_error_when_the_binary_is_missing
def test_build_offline_reports_a_clear_error_when_planetiler_is_missing
select_regions("SK")
ENV["PMTILES_BIN"] = File.join(@bin_dir, "does-not-exist")
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" })
ENV["JAVA_BIN"] = File.join(@bin_dir, "does-not-exist")
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join
status = Maptool::PmtilesBuilder.offline_status
assert_equal "error", status[:state]
assert_match(/go-pmtiles installed/, status[:message])
assert_match(/Java and planetiler\.jar installed/, status[:message])
end
# fetch_build_url blocks on a Queue so the build is deterministically
# download_extract blocks on a Queue so the build is deterministically
# still "running" when the second call happens, rather than racing a real
# one to see which state wins.
def test_build_offline_raises_when_one_is_already_running
select_regions("SK")
ENV["PMTILES_BIN"] = @pmtiles_ok
gate = Queue.new
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { gate.pop })
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: ->(_url, _dest) { gate.pop })
assert_raises(Maptool::PmtilesBuilder::BuildError) do
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "unused" })
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
end
ensure
gate.push("https://example.com/fake.pmtiles")
gate.push("Wed, 10 Sep 2026 08:00:01 GMT")
thread&.join
end
-1
View File
@@ -20,7 +20,6 @@
}
</script>
<script src="/vendor/pmtiles/pmtiles.js"></script>
<script src="/vendor/protomaps-themes-base/protomaps-themes-base.js"></script>
<script src="/vendor/maplibre-contour/maplibre-contour.min.js"></script>
<%# Vendored libs above don't change between deploys, so they're left
unversioned; app.js's own modules do, and map.js statically imports