Replace offline basemap pipeline with Planetiler, unify online/offline styling
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:
1 parent
0874e8f8ed
commit
d08ed41185
21 files changed
+4283
-509
No files matched your search
+17
-3
@@ -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 ./
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+190
-77
@@ -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
|
||||
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"] }
|
||||
end
|
||||
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
@@ -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: "© 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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
+1850
File diff suppressed because it is too large.
Load diff
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
+1850
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
+1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user