Compare commits

...
43 Commits
Author SHA1 Message Date
randy efddc55ad0 refactor: enhance Overpass API handling with multiple endpoints and improved error reporting
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
2026-10-09 22:07:45 +02:00
randy 33be95ef59 Send a User-Agent to Overpass, overpass-api.de 406s Ruby's default
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 13s
The Camper POIs plugin died with "plugin exited with status 1": Net::HTTP's
bare "Ruby" User-Agent now gets a 406 Not Acceptable from overpass-api.de,
any identifying one goes through.
2026-10-09 21:51:24 +02:00
randy ede21454a2 Draw offline hiking trails sharing a path as parallel lanes
CI / check (push) Successful in 22s
CI / deploy (push) Successful in 12s
Several marked routes running down one path drew as a single line, only
the topmost color visible. maplibre-gl-lanes puts each route in its own
lane; OSM relations already share exact vertices, which is all it needs.
One LaneLayer across every picked country; click hit-testing and the
highlight move to queryLane/setHighlight.
2026-10-09 21:46:05 +02:00
randy 9c80a20f3e compose: enable IPv6 on default network (fixes broken container DNS on brick)
CI / check (push) Successful in 38s
CI / deploy (push) Successful in 12s
2026-10-08 12:28:05 +02:00
randy 43785a074b Note the NPS arrowhead logo's official Pantone/RAL colors
CI / check (push) Successful in 38s
CI / deploy (push) Successful in 12s
Unrelated to anything the app renders, but the doc already collects
NPS-ecosystem color facts and this came up while sourcing colors
for a 3D print of the shield.
2026-09-21 18:08:35 +02:00
randy b0c93d10d7 Detect a LiveTrack session ending the moment Garmin confirms it, not 30min later
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 12s
Watched the session from the last two commits actually get saved:
Garmin updated its `end` field from the future 24h placeholder to
within a second of the last point's own time, immediately — much
faster than the recency-of-last-point fallback alone would have
noticed (that only flips after ACTIVE_WITHIN with no new points, so a
just-saved session with a last point seconds old would still read as
active for up to 30 more minutes).

Now checks end-already-in-the-past first (authoritative and near
instant when Garmin has set it), falling back to point recency only
when end still shows the future placeholder — the case an abandoned,
never-saved session never updates.
2026-09-20 16:55:57 +02:00
randy 3106f8c103 Draw a LiveTrack session's planned course alongside the actual track
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 13s
Garmin's own LiveTrack page already shows both when the activity was
started from a saved course — the session payload carries the course's
geoPoints right alongside the track points (LiveTrackClient found it
nested under the same React Query cache the session/points come from).

Rendered as a second, dashed LineString distinct from the actual
track's own line ("kind": "actual" vs "planned" on each GeoJSON
feature) — muted gray rather than matching Garmin's own purple-for-
planned, since this app's actual track is already purple and reusing
it would make the two indistinguishable.
2026-09-20 16:54:24 +02:00
randy 8eada07869 Drop subject:LiveTrack from the Apps Script's Gmail search, it finds nothing
CI / check (push) Successful in 21s
CI / deploy (push) Successful in 12s
Documented as matching regardless of language, on the assumption
Gmail's subject: operator does a substring match — empirically false
against a real, currently-arriving LiveTrack email whose subject does
contain the word ("Sledujte LiveTrack používateľa ...", confirmed
2026-09-20): with the filter, the search returned zero threads;
removing it and rerunning found the email immediately. Left out
entirely rather than replaced — from:noreply@garmin.com plus the
session-link regex already narrows this down to real LiveTrack emails
with something usable in them.
2026-09-20 16:44:20 +02:00
randy 5d86cb1d04 Detect a live LiveTrack session from its last point, not session["end"]
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s
session["end"].nil? was the active signal since this was first built,
on the assumption Garmin leaves it unset until the activity actually
stops. Checked directly against a real still-recording session today
(2026-09-20): end was already non-nil, set to exactly start + 24h — the
share link's own expiry, not an end time — so every currently-active
session was showing as "Ended". A genuinely ended session's end does
hold its real end time (confirmed against the 2026-09-06 session this
was originally, and only, verified against), which is what made the
bug easy to miss.

Recency of the last track point is a directly-observable signal
instead of reverse-engineering what a given `end` value means: Garmin
posts one roughly every postTrackPointFrequency seconds (~15, observed)
while genuinely still recording.
2026-09-20 16:42:52 +02:00
randy b7618edc25 Move the session-skip from a before filter to real Rack middleware
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
The before filter added in the previous commit never ran for static
assets: Sinatra serves public_folder files (every JS/CSS/font/vendor
file) through its own built-in shortcut, bypassing route and filter
dispatch entirely — confirmed live, curl still got a fresh set-cookie
on /js/map.js after that commit deployed, and the user hit the exact
same logout on refresh again. A HAR from that retest shows the same
clobbering pattern as before, just via a different response each time.

SkipSessionWrite is a plain Rack middleware instead, positioned (via
`use`, after `set :sessions`) inside Rack::Session::Cookie's own
wrapping — it sees the same env static dispatch does, so every response
is covered, not just Sinatra-routed ones.
2026-09-16 18:03:28 +02:00
randy bd46b72144 Skip re-writing the session cookie on every response, not just the three that change it
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
Rack::Session::Cookie re-serializes and re-sets the cookie on every
response that even reads session — Rack::Protection's CSRF check does,
on nearly every request — reflecting whatever cookie that particular
request happened to arrive with, whether or not anything actually
changed. A HAR capture caught the real failure mode: seconds after a
successful login, offline_data.js's _checkUpdate (an unrelated
background poll fired the instant the page loads) was still carrying
the browser's pre-login cookie — a client-side timing race across the
OAuth redirect chain, not something this server controls — and its own
re-issued Set-Cookie landed after the login's, silently overwriting
session[:user] with nothing logged anywhere to explain it.

Only /login, /auth/callback and /logout ever actually write to
session. A before filter now skips the write everywhere else, removing
every other response from that race — a stale request can still read a
stale cookie, but it can no longer clobber a fresher one.

Also drops the earlier except: [:session_hijacking] from the previous
commit: rack-protection 4.2.1 only enables SessionHijacking when
explicitly opted into via :use, so Sinatra's default setup never turned
it on in the first place — the exclusion was a no-op, not the fix.
2026-09-16 17:56:44 +02:00
randy 6126b5055b Disable Rack::Protection::SessionHijacking, it silently logs everyone out
CI / check (push) Successful in 37s
CI / deploy (push) Successful in 19s
Sinatra turns this on by default once sessions are enabled: it pins a
session to the User-Agent header seen on whichever request first wrote
to it, and empties the session — no exception, nothing logged — on any
later request where that header doesn't match. That's exactly what
production hit behind its reverse proxy: OAuth login set session[:user]
fine, then the very next page load (even a plain refresh) came back
logged out with nothing in the logs to explain why.

Its own docs concede every header it checks is spoofable anyway ("will
not prevent determined hijacking attempts"), so losing it is a minor
tradeoff for a login that actually stays logged in. Every other
Rack::Protection check stays on.
2026-09-16 16:11:01 +02:00
randy 9edc2d9534 Show clicked hiking trail info in a docked panel, not a popup
CI / check (push) Successful in 22s
CI / deploy (push) Successful in 12s
A map-anchored Popup on a trail click routinely overlapped the very
line it described. Follows the same click -> docked panel pattern
gpx.js already uses for a track line click (TrackProfilePanel) rather
than inventing a new one: a fixed #trail-panel docks to the right edge
on desktop and the bottom edge on narrow viewports (this file's first
@media breakpoint), so the trail stays fully visible either way.

routeHtml()'s own markup is reused unchanged for the panel body —
.trail-panel__body's padding matches .maplibregl-popup-content's own
(15px 10px) so the header's canceling negative margin still lines up.
The popup-identity staleness guard for the async Overpass lookup is
replaced with a plain request token, since there's no longer a fresh
Popup instance to key off of.
2026-09-15 12:52:54 +02:00
randy eada464fb3 Highlight trails in their own color instead of recoloring to yellow
CI / check (push) Successful in 22s
CI / deploy (push) Successful in 12s
2026-09-13 20:17:48 +02:00
randy ca7772dd72 Highlight the clicked trail(s) on the map, both online and offline
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
The real app had no visual feedback for which line a click actually
matched, unlike the earlier SVG prototype's hover highlight — just a
popup. Draws the matched route's real geometry (from the offline
vector feature, or reassembled from the online Overpass response's own
member-way geometry) as a separate yellow highlight layer, cleared
when the popup closes.

Guarded against a stale popup's closeOnClick "close" event clearing a
highlight that a newer click already set in the same event dispatch —
tracks which popup is currently "active" and only clears on its own
close, not a predecessor's delayed one.
2026-09-13 19:28:51 +02:00
randy 4c95927231 Pin check/renovate back to brick-build now that its image has Node
CI / check (push) Successful in 38s
CI / deploy (push) Successful in 12s
The runner's label-to-image mapping got repointed at Gitea's own
official runner image (docker.gitea.com/runner-images:ubuntu-latest,
has Node out of the box) — the earlier opensuse/leap:16.0 mapping for
brick-build specifically was what broke check/renovate on 2026-09-12,
not brick-build the label itself. Back to pinning both to this
machine specifically instead of whichever runner answers to the
shared ubuntu-latest label.
2026-09-13 12:32:14 +02:00
randy c6e4a455b6 Split offline hiking trails per country, add a picker instead of one big file
CI / check (push) Successful in 21s
CI / deploy (push) Successful in 11s
data/hiking_trails.geojson is now data/hiking_trails/<CODE>.geojson —
one file per country instead of a merge, generated per-source
(PmtilesBuilder.extract_hiking_trails), cached the same way
geofabrik_cache is (skipped once a country's own extract is already
newer than its source .osm.pbf, so growing the selection by one
country only re-extracts that one). GET /api/hiking-trails now lists
available country codes instead of a single file's status.

The Overlays panel's "Hiking trails" item becomes an expandable
per-country checklist once any offline extract exists (same shape as
Radar's own expand), replacing the plain on/off checkbox — nobody
covering a dozen countries' worth of offline map data needs all of
their hiking trails loaded and parsed at once, so each ticked country
fetches its own GeoJSON independently instead.

HikingTrails.extract_merged (server-side multi-country merge, no
longer needed now that the client picks per country) removed again.
Verified the new extract_hiking_trails + caching skip live on brick
against a real cached country PBF.
2026-09-13 11:17:05 +02:00
randy 079ac37787 Wire offline hiking trails into the real build, API, and renderer
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
data/hiking_trails.geojson now generates automatically as the last
step of every offline.pmtiles build (PmtilesBuilder.extract_hiking_trails,
best-effort — a failure here doesn't fail the whole build), merged
across all selected countries with cross-border dedup by OSM relation
id (HikingTrails.extract_merged). Served at GET /tiles/hiking_trails.geojson
and GET /api/hiking-trails, fetched client-side only once the overlay
is actually toggled on.

hiking_trails.js now prefers this local file whenever it exists,
regardless of which base map (online/offline) is active — same
"prefer local when available" story as HillshadeControl already uses
for terrain. Offline click hit-tests the real vector layer directly
(queryRenderedFeatures) instead of querying Overpass — instant, no
network. Coordinates are rounded to 5dp to keep the combined file
smaller; colour tag values are normalized against a known-safe set
before ever reaching a style paint property.

Verified extract_merged (new: combines multiple countries' extracts,
dedupes overlapping relations) against two real cached country PBFs
on brick directly, not just fixtures.
2026-09-13 10:31:59 +02:00
randy b35ace9734 Fix hiking trails extraction: add-locations-to-ways was missing
Verified live on brick against real cached extracts: a plain Geofabrik
PBF's ways only carry bare node references, not coordinates, so
locations_on_ways on the final `cat -f opl` had nothing to embed —
every way came out as e.g. n123xy, empty x/y, and every relation got
dropped for having no usable geometry (0 routes for Macedonia).

osmium add-locations-to-ways between the tags-filter and cat steps
resolves those references first. Re-verified after the fix: 45 routes
for Macedonia, 3009 for Slovakia, with real colour/ref/operator/
distance (326 red, 782 yellow, 510 blue, 902 green KST-style routes).
2026-09-13 09:26:22 +02:00
randy 714795d52b Prototype: extract offline hiking trails from the Geofabrik PBF we already have
CI / check (push) Successful in 43s
CI / deploy (push) Successful in 13s
No custom Planetiler profile needed after all — Maptool::HikingTrails
runs osmium tags-filter r/route=hiking against the same .osm.pbf
PmtilesBuilder already downloads per country for offline.pmtiles, then
osmium cat -f opl (locations_on_ways=true) to get member-way geometry
back as plain text, no separate node-id lookup table to build.

No general OSM tool stitches a route relation into one line (routes
are a semantic grouping, not a geometry type) — parse_opl does that
walk itself, same as the Overpass-based click handler already does
client-side. Kept pure/testable apart from the two osmium subprocess
calls, mirroring OverpassPois.fetch vs .to_geojson.

rake hiking:trails[pbf,out] to try it by hand against a real cached
extract. Not wired into the actual offline build or rendering yet —
still just a prototype, per TODO.adoc.
2026-09-13 09:18:50 +02:00
randy cc3d8ca5f3 Race 3 Overpass mirrors for speed, show more per-route detail
CI / check (push) Successful in 2m18s
CI / deploy (push) Successful in 11s
The single shared overpass-api.de instance varies wildly in load —
Promise.any against three independent public mirrors (also CORS-open,
verified) means only the slowest one determines wait time, not
whichever happens to be hit.

Also show the description tag (falls back to it for the title when a
route has no name, e.g. many KST route relations), the ref number, and
a "View on OpenStreetMap" link — the popup was skipping data that was
already in the response for free.
2026-09-13 08:53:48 +02:00
randy 6f1577f957 Click a hiking trail to see its name, color, network and distance
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
The Waymarked Trails raster overlay has no queryable features (just
pixels), so this answers "what trail is this" with a one-off Overpass
query on click instead: way(around:40m) finds candidate ways, then
rel(bw) walks backward to their parent route=hiking relations. Overpass
has permissive CORS, so this runs straight from the browser, no server
proxy needed — verified against a real KST route in the Tatras.

Route length isn't tagged directly, so it's summed from each member
way's own geometry (measure.js's existing haversine lineLength, now
exported and reused rather than duplicated). The colour tag is only
ever used as a CSS background after matching a strict hex/word regex —
untrusted free text otherwise.
2026-09-12 23:32:56 +02:00
randy 9c45e7275c Add a Hiking trails overlay (Waymarked Trails, online only)
CI / check (push) Successful in 2m16s
CI / deploy (push) Successful in 11s
New Overlays panel toggle, same plain on/off shape as Contour lines —
raster tiles from waymarkedtrails.org, which already color each route
by its OSM colour tag (the KST/KČT paint-blaze colors used in
Slovakia/Czechia, plain red elsewhere), so no styling work needed here.

Online only for now; offline (baked into offline.pmtiles) needs a
custom Planetiler profile for route relations, deferred until there's
a route planner/router to pair it with.
2026-09-12 22:36:10 +02:00
randy c894706140 Revert check/renovate to ubuntu-latest — brick-build's image has no Node
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
brick-build's job container (opensuse/leap:16.0) has no Node.js, and
every step in check/renovate (checkout, setup-ruby, setup-node) is a
JS-based Action that needs it just to start — both runs since the
brick-build switch failed immediately, and deploy never ran because it
depends on check passing. ubuntu-latest is shared with "Gitea Runner on
Cube" (not pinned to this specific machine), but that's fine: only
deploy (a unique label held by just brick's native runner) actually
needs to land on a specific box.
2026-09-12 21:39:21 +02:00
randy 21988c61da Lower offline terrain to z13, not z15 — raster DEM storage explodes with zoom
CI / check (push) Failing after 4s
CI / deploy (push) Skipped
The 12-country selection's terrain.pmtiles was headed well past the
181GB an earlier 5-country build already produced at z15. Raster DEM
tiles don't compress away like vector ones (every tile is a real
image, even over empty terrain), so each extra zoom level roughly
quadruples storage. z13 is each Mapterhorn regional chunk's own native
minzoom — one raster level per chunk instead of a 15-13=2-level
pyramid on top — still enough for the hillshade toggle and the planned
slope readout. Online hillshade keeps its own z15 cap; this only
affects the offline copy.
2026-09-12 20:18:36 +02:00
randy f18e5c19d3 Truncate noisy build-failure output, fix the recurring terrain death
CI / check (push) Failing after 4s
CI / deploy (push) Skipped
The web UI was showing a raw ~500k-character stdout/stderr dump on a
failed build — go-pmtiles' extract logs one progress line per chunk,
which is fine on a terminal (it overwrites itself) but not captured
non-interactively. command_output now keeps just the tail, and the
status line renders it with preserved line breaks in a scrollable box
instead of one unreadable blob.

Root cause of the actual failure (terrain build dying ~11 minutes in
with an HTTP/2 stream reset): go-pmtiles' default --overfetch=0.05
merged this multi-country planet.pmtiles extract into a single ~60GB
request with no way to resume after a dropped stream. --overfetch=0
splits it into many smaller requests instead.
2026-09-12 19:41:45 +02:00
randy 237e5b2e54 Move check/renovate onto brick-build, deploy stays brick-host
Renovate no longer needs Docker access for a one-shot docker-run job:
switched to setup-node + npm install -g renovate (matching labelprint's
workflow), so it fits the same brick-build runner as check instead of
the stale "deploy" label. Also note down the build-log readability/API
gaps flagged from a real failed terrain build.
2026-09-12 19:36:08 +02:00
randy 3f9c46e846 Skip rebuilding offline/terrain data when nothing actually changed
CI / check (push) Successful in 21s
CI / deploy (push) Successful in 12s
2026-09-12 13:32:02 +02:00
randy 17d1adedbc Guard syncTerrain against running before the style has loaded
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 12s
2026-09-12 13:27:53 +02:00
randy cd02b03502 Retry pmtiles extract/merge on failure, not just on the first try
CI / check (push) Successful in 39s
CI / deploy (push) Successful in 12s
2026-09-12 12:50:09 +02:00
randy 5c3151c2f5 Add a 3D pitch toggle, with raised terrain following it 2026-09-12 12:50:09 +02:00
randy f5eb9a4a8d Tell Planetiler explicitly how many CPUs it has
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 12s
Its own autodetection was leaving most of a 16-core host idle
(observed in production) — likely a container cgroup/JVM detection
quirk. Etc.nprocessors asks the OS directly instead.
2026-09-12 01:34:29 +02:00
randy f09b5de6c2 Relabel data bind mounts for SELinux (:Z)
CI / check (push) Successful in 21s
CI / deploy (push) Successful in 12s
App crashed on startup on brick (Permission denied @ dir_s_mkdir -
/app/data/gpx) — brick has SELinux enforcing, unlike cube, and Podman
denies a container write access to a bind-mounted host directory
without a relabel regardless of Unix permissions. Applied to both
maptool's ./data mount and kiwix's ZIM mount (same underlying cause,
would have hit this too once a real ZIM is placed there).
2026-09-12 01:27:23 +02:00
randy 0080ac6170 Deploy to brick-host instead of cube-host
CI / check (push) Successful in 19s
CI / deploy (push) Failing after 5m1s
cube.local and brick.local swapped IPs and brick is now the deploy
target — rename the native runner label everywhere it's referenced.
2026-09-12 01:15:09 +02:00
randy 78362d51f2 Show what's actually cached per-country, with a delete button per row
CI / check (push) Successful in 17s
CI / deploy (push) Successful in 13s
The only visibility into downloaded data was the whole offline.pmtiles
row, and the only delete was "everything" (whole file + whole cache).
GET /api/offline-tiles/cache lists what's really in
data/geofabrik_cache/ right now — distinct from offline.pmtiles' own
meta.sources, which only reflects the last successful build and can
disagree with both the current selection and the cache (ticked-but-
not-Applied, or built-from-but-since-unticked).

The region picker now shows each cached country's size next to its
checkbox, with its own delete button (DELETE
/api/offline-tiles/cache/:code, already added but unused by the UI
until now) — removing just that entry rather than clearing the whole
cache.
2026-09-11 23:39:43 +02:00
randy d09d953cbf Pass --download to Planetiler for its auxiliary OpenMapTiles sources
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 13s
Real build failure once the tmp-filename fix got past the format-
detection error: "data/sources/lake_centerline.shp.zip does not exist.
Run with --download to fetch it". The OpenMapTiles profile needs a few
small non-OSM sources (water polygons, Natural Earth, lake
centerlines) alongside the .osm.pbf itself, and Planetiler only
fetches them when told to. They land under its own default
data/sources/, gitignored alongside the rest of data/ — fetched once,
reused by every later build.
2026-09-11 23:28:07 +02:00
randy b54020e23b Fix Planetiler tmp-file format detection, add build progress and delete
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 13s
Root cause of "planetiler exited with status 1: ... Unsupported format
tmp" (and likely the earlier unexplained "pmtiles exited with status
1"): Planetiler/go-pmtiles pick their output archive format from the
file extension, and the atomic-rename tmp files were named
"offline.pmtiles.tmp"/"terrain.pmtiles.tmp" — ".tmp" as the extension,
not ".pmtiles". Renamed to "offline.tmp.pmtiles"/"terrain.tmp.pmtiles".

Also, since build-status only ever said "running since X" with no way
to tell what it was actually doing:
- GET .../build-status now includes a `step` string ("fetching PL
  (3/4) — done: SK, CZ — pending: HU", "tiling with planetiler", ...)
  while a build is in progress.
- DELETE /api/offline-tiles / /api/terrain-tiles clear a built file,
  its sidecar meta, and (for offline) the whole per-country download
  cache — a manual "reclaim space" / "start over" action the region
  picker's Apply never did on its own. DELETE
  /api/offline-tiles/cache/:code clears just one country's cached
  extract. All three are 409 while that kind's build is running, and
  surfaced as a small "Delete" button on each row in the Offline data
  panel.
2026-09-11 23:23:46 +02:00
randy 2878270187 Include stdout in run_*! build error messages, not just stderr
Real cause of "pmtiles exited with status 1:" with nothing after the
colon: go-pmtiles (like some other CLIs) can write its actual error to
stdout, which was being silently discarded. Applies the same fix to
osmium/planetiler for consistency.
2026-09-11 23:07:49 +02:00
randy 007dee7341 Cache per-country Geofabrik extracts so Apply doesn't redownload everything
Every region-selection change re-downloaded every selected country's
.osm.pbf from scratch, even ones already fetched by a prior build —
painful for multi-GB countries. Extracts now persist in
data/geofabrik_cache/, keyed by country, and are only re-fetched when
Geofabrik's Last-Modified for that country actually moved on (checked
with a cheap HEAD) or the file isn't cached yet.
2026-09-11 22:52:16 +02:00
randy 077b44ece1 Note OpenTopoMap-style forest/meadow fill patterns as a TODO idea
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 2m54s
2026-09-11 22:13:30 +02:00
randy 619f2293df Move Docker base image to Debian trixie for Java 21
Planetiler 0.10.2 needs Java 21+; bookworm's default-jre-headless (and
its backports) only goes up to 17, causing a real build to fail with
UnsupportedClassVersionError. trixie's default-jre-headless is Java 21.
2026-09-11 22:13:30 +02:00
randy a84e6ae22f Follow Geofabrik's 302 redirects on extract download and Last-Modified check
CI / check (push) Successful in 29s
CI / deploy (push) Successful in 16s
Geofabrik redirects europe/<slug>-latest.osm.pbf to a dated filename
(confirmed live: .../europe/austria-latest.osm.pbf -> .../europe/austria-
260910.osm.pbf) — a real build hit this immediately. Net::HTTP doesn't
follow redirects on its own; both the streaming download and the HEAD-based
update-check now do, up to a small limit.
2026-09-11 20:57:22 +02:00
randy 920a1d2d36 Stream Geofabrik extract downloads instead of buffering in memory
Net::HTTP's plain #get loads the whole response body before returning —
fine for the small JSON manifests elsewhere in this file, but a real risk
for a multi-GB country .osm.pbf (Germany, Russia, ...). Streams to disk
via read_body instead.
2026-09-11 20:56:06 +02:00
33 changed files with 5977 additions and 139 deletions

No files matched your search

+12 -5
View File
@@ -6,7 +6,14 @@ on:
jobs: jobs:
check: check:
runs-on: ubuntu-latest # brick-build's job container now points at Gitea's own official
# runner image (docker.gitea.com/runner-images:ubuntu-latest, has
# Node out of the box) — the earlier opensuse/leap:16.0 image that
# failed here on 2026-09-12 (no Node, every step below is a JS-based
# Action) got repointed. Pinned to brick specifically again, rather
# than the shared ubuntu-latest label "Gitea Runner on Cube" also
# answers to.
runs-on: brick-build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -34,13 +41,13 @@ jobs:
run: find public/js -name "*.js" -print0 | xargs -0 -n1 node --check run: find public/js -name "*.js" -print0 | xargs -0 -n1 node --check
# Handled by a separate, native (non-Docker) runner registered with the # Handled by a separate, native (non-Docker) runner registered with the
# single "cube-host" label (see docs/deployment.adoc) — it runs directly # single "brick-host" label (see docs/deployment.adoc) — it runs directly
# on the server, so it has podman/git/etc. from the host, no socket/bind # on the server, so it has podman/git/etc. from the host, no socket/bind
# mount tricks needed. The Docker-based runner keeps handling `check`. # mount tricks needed. The Docker-based runner keeps handling `check`.
deploy: deploy:
needs: check needs: check
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: cube-host runs-on: brick-host
steps: steps:
# Not actions/checkout@v4: keeping this plain-git checkout from when # Not actions/checkout@v4: keeping this plain-git checkout from when
# the job ran in a Node-less container — no reason to add a Node # the job ran in a Node-less container — no reason to add a Node
@@ -114,14 +121,14 @@ jobs:
# layers (apt-get/bundle install) as cache — unlike explicitly # layers (apt-get/bundle install) as cache — unlike explicitly
# removing the image first, which would delete that cache along with # removing the image first, which would delete that cache along with
# it. sudo is needed since the unit is root's, not gitea-runner's own # it. sudo is needed since the unit is root's, not gitea-runner's own
# (gitea-runner has passwordless sudo for this on cube-host). # (gitea-runner has passwordless sudo for this on brick-host).
- name: Deploy - name: Deploy
run: sudo systemctl restart podman-compose@maptool.service run: sudo systemctl restart podman-compose@maptool.service
# systemctl restart above only confirms the process forked # systemctl restart above only confirms the process forked
# (Type=simple, no readiness protocol) — not that `podman compose up # (Type=simple, no readiness protocol) — not that `podman compose up
# --build` actually finished and Puma is answering. Polls the health # --build` actually finished and Puma is answering. Polls the health
# endpoint (cube-host itself, same machine the container's port is # endpoint (brick-host itself, same machine the container's port is
# published on) instead of trusting that — checking for this # published on) instead of trusting that — checking for this
# deploy's own revision, not just any 200, so a stuck old container # deploy's own revision, not just any 200, so a stuck old container
# that never got torn down can't pass as a successful deploy of the # that never got torn down can't pass as a successful deploy of the
+20 -12
View File
@@ -8,17 +8,25 @@ on:
jobs: jobs:
renovate: renovate:
# The native deploy runner (see docs/deployment.adoc) already has # Same reasoning as check's runs-on in ci.yml: brick-build's job
# Docker access — Renovate itself is a one-shot batch job, not a # container now has Node (repointed at Gitea's own official runner
# long-running service, so `docker run`, not docker-compose. # image), which setup-node/npm below need.
runs-on: deploy runs-on: brick-build
steps: steps:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install Renovate
run: npm install -g renovate
- name: Run Renovate - name: Run Renovate
run: | run: renovate
docker run --rm \ env:
-e RENOVATE_PLATFORM=gitea \ RENOVATE_PLATFORM: gitea
-e RENOVATE_ENDPOINT="${{ gitea.server_url }}/api/v1/" \ RENOVATE_ENDPOINT: "${{ gitea.server_url }}/api/v1/"
-e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \ RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \ RENOVATE_REPOSITORIES: "${{ gitea.repository }}"
-e RENOVATE_ONBOARDING=false \ RENOVATE_ONBOARDING: "false"
renovate/renovate GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
+11
View File
@@ -12,6 +12,17 @@
# see docs/development.adoc) # see docs/development.adoc)
/data/*.pmtiles /data/*.pmtiles
# Per-country .osm.pbf cache for offline.pmtiles rebuilds (also large,
# also local-only — see PmtilesBuilder.cached_extract)
/data/geofabrik_cache/
# Planetiler's own auxiliary-source cache and scratch working directory
# (water polygons/Natural Earth/lake centerlines, --download; temp feature
# DBs, --tmpdir) — relative to the app's CWD, which lands them here
# alongside the rest of data/ (see extract_offline's --download comment)
/data/sources/
/data/tmp/
# Offline Wikipedia ZIM for kiwix-serve (also large, also local-only — # Offline Wikipedia ZIM for kiwix-serve (also large, also local-only —
# same story, see docs/development.adoc) # same story, see docs/development.adoc)
/data/kiwix/* /data/kiwix/*
+6 -2
View File
@@ -1,4 +1,7 @@
FROM ruby:4.0.6-slim-bookworm # trixie, not bookworm: Planetiler needs Java 21+ (UnsupportedClassVersionError
# otherwise) and bookworm's own default-jre-headless is only Java 17 —
# bookworm-backports doesn't carry a newer one either, only trixie's does.
FROM ruby:4.0.6-slim-trixie
# build-essential: puma has a native (C) extension; harmless if the # build-essential: puma has a native (C) extension; harmless if the
# platform-specific gem in Gemfile.lock already has a precompiled binary # platform-specific gem in Gemfile.lock already has a precompiled binary
@@ -6,7 +9,8 @@ FROM ruby:4.0.6-slim-bookworm
# ca-certificates/curl: needed for the go-pmtiles/planetiler.jar downloads # ca-certificates/curl: needed for the go-pmtiles/planetiler.jar downloads
# below (also handy for anyone debugging inside a running container). # below (also handy for anyone debugging inside a running container).
# default-jre-headless: Planetiler is a JVM tool (see PLANETILER_VERSION # default-jre-headless: Planetiler is a JVM tool (see PLANETILER_VERSION
# below) — headless since this never touches a display. # below) — headless since this never touches a display; trixie's version
# of this package is Java 21, see above.
# osmium-tool: merges multiple countries' OSM extracts into one file before # osmium-tool: merges multiple countries' OSM extracts into one file before
# handing them to Planetiler (lib/maptool/pmtiles_builder.rb) — a normal # handing them to Planetiler (lib/maptool/pmtiles_builder.rb) — a normal
# Debian package, no separate download/pinning needed like the other two. # Debian package, no separate download/pinning needed like the other two.
+16
View File
@@ -24,6 +24,22 @@ namespace :overpass do
end end
end end
namespace :hiking do
desc "Extract route=hiking relations (name/colour/network/operator, full geometry) from a local " \
".osm.pbf (e.g. one of PmtilesBuilder's own data/geofabrik_cache/*.osm.pbf) and write GeoJSON. " \
"Prototype — see lib/maptool/hiking_trails.rb. Usage: rake hiking:trails[input.osm.pbf,out.geojson]"
task :trails, %i[pbf out] do |_t, args|
%i[pbf out].each { |key| abort("usage: rake hiking:trails[input.osm.pbf,out.geojson]") unless args[key] }
require_relative "lib/maptool/hiking_trails"
puts "Extracting hiking routes from #{args[:pbf]}..."
geojson = Maptool::HikingTrails.extract(args[:pbf])
File.write(args[:out], JSON.generate(geojson))
puts "Wrote #{geojson["features"].size} route(s) to #{args[:out]}"
end
end
namespace :gpx do namespace :gpx do
desc "Backfill properties.id on Note-category features saved before edit/delete (and its id targeting) existed" desc "Backfill properties.id on Note-category features saved before edit/delete (and its id targeting) existed"
task :backfill_note_ids do task :backfill_note_ids do
+12
View File
@@ -12,6 +12,8 @@ independence from connectivity.
* [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 * [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) ** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] Contour lines from OSM for hikes and mountain passes * [ ] Contour lines from OSM for hikes and mountain passes
* [x] Hiking trail overlay — Waymarked Trails raster tiles online (`public/js/hiking_trails.js`), colored per-route by the OSM `colour` tag they already render (the KST/KČT paint-blaze colors in Slovakia/Czechia, plain red elsewhere). Click queries a one-off Overpass `around` search (racing 3 public mirrors) for the route's name/colour/network/operator/distance
** [x] Offline copy wired in for real, no custom Planetiler profile needed — `Maptool::HikingTrails` (`lib/maptool/hiking_trails.rb`) runs `osmium tags-filter r/route=hiking` + `add-locations-to-ways` + `cat -f opl` against the *same* per-country Geofabrik `.osm.pbf` already downloaded for `offline.pmtiles`, no new download. One `data/hiking_trails/<CODE>.geojson` per country (not one combined file), generated as the last step of every offline build and cached like `geofabrik_cache` (skipped once already newer than its source PBF) — a many-country selection no longer means one giant file, since `GET /api/hiking-trails` lists available countries and the Overlays panel's own checklist only fetches `GET /tiles/hiking_trails/<CODE>.geojson` for whichever ones get ticked. Offline click uses a local `queryRenderedFeatures` hit-test instead of Overpass — instant, no network. See docs/development.adoc
* [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query). Each category now carries a real `POI_ICONS` glyph and color (not p4n2gpx's plain red marker) plus a popup description built from opening_hours/operator/website tags * [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query). Each category now carries a real `POI_ICONS` glyph and color (not p4n2gpx's plain red marker) plus a popup description built from opening_hours/operator/website tags
* [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed * [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed
** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story ** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story
@@ -30,6 +32,7 @@ independence from connectivity.
** [x] Filter panel as a "Unigrid Matrix" — an icon grid to toggle categories on/off, styled like the legend on an official NPS paper map, instead of plain dropdowns ** [x] Filter panel as a "Unigrid Matrix" — an icon grid to toggle categories on/off, styled like the legend on an official NPS paper map, instead of plain dropdowns
* [ ] Custom vector style (MVT/MapLibre) using the NPS color palette — muted natural tones for forests, water, and contours — so POIs and routes stand out at high zoom * [ ] Custom vector style (MVT/MapLibre) using the NPS color palette — muted natural tones for forests, water, and contours — so POIs and routes stand out at high zoom
** [ ] Landuse & protections: distinct styling for national parks, nature reserves (translucent green/gray-green), military zones, and private land (important for wild camping) ** [ ] Landuse & protections: distinct styling for national parks, nature reserves (translucent green/gray-green), military zones, and private land (important for wild camping)
** [ ] OpenTopoMap-style `fill-pattern` texture for forest (tree icons) and meadow/pasture (grass texture) landcover, instead of flat fill colors — Liberty's own sprite (`public/vendor/liberty-sprite/`) only has a `wetland` dot pattern, no tree/grass, so this needs custom pattern images added to the sprite sheet; works offline and online alike since it's just a style/sprite change, unlike pulling in OpenTopoMap's own raster tiles (online-only, restrictive tile-usage policy, wouldn't match the NPS palette)
** [x] Elevation-tinted terrain — a `color-relief` layer (hypsometric tint, this app's own green→brown→gray palette) under the hillshade layer, same "Terrain shading" toggle ** [x] Elevation-tinted terrain — a `color-relief` layer (hypsometric tint, this app's own green→brown→gray palette) under the hillshade layer, same "Terrain shading" toggle
** [x] Road hierarchy: clearly distinguish paved roads from unpaved/gravel/track — critical for knowing if the camper can get there ** [x] Road hierarchy: clearly distinguish paved roads from unpaved/gravel/track — critical for knowing if the camper can get there
@@ -70,3 +73,12 @@ Deprioritized for now — revisit after the sections above land.
* [ ] Level gauge from IMU (ESP32/RPi) — camper tilt, wheel-chock leveling calculation * [ ] Level gauge from IMU (ESP32/RPi) — camper tilt, wheel-chock leveling calculation
* [ ] Victron/Home Assistant via MQTT or WebSocket — battery SoC, tank levels, temperatures * [ ] Victron/Home Assistant via MQTT or WebSocket — battery SoC, tank levels, temperatures
* [ ] Plot GPS track with historical battery/speed data * [ ] Plot GPS track with historical battery/speed data
== Admin / ops tooling
* [ ] Offline build log readability — a failed pmtiles/terrain build currently surfaces as one
~500k-character line; format it for the web UI instead (e.g. per-country/state name + status,
not the raw combined stdout/stderr dump)
* [ ] Expose the build log via an API endpoint, not just embedded in the admin page
* [ ] Document all API endpoints somewhere and make them easy to browse (status of each) —
maybe a status page, login-gated
+127 -5
View File
@@ -15,6 +15,40 @@ require_relative "lib/maptool/plugins"
require_relative "lib/maptool/pmtiles_builder" require_relative "lib/maptool/pmtiles_builder"
module Maptool module Maptool
# Only /login, /auth/callback and /logout ever write to session
# (oauth_state, user, session.clear) — everything else only ever reads
# it, if it touches session at all. That still matters:
# Rack::Session::Cookie re-serializes and re-sets the cookie on *every*
# response that so much as read session (Rack::Protection's own CSRF
# check does, on nearly every request) — reflecting whatever cookie
# that particular request happened to arrive with, whether or not
# anything changed. A HAR capture caught the actual failure mode this
# caused: seconds after a successful login, a background poll fired the
# instant the page loads was still carrying the browser's pre-login
# cookie (a client-side timing race across the OAuth redirect chain,
# not something this server controls), and its own re-issued Set-Cookie
# landed *after* the login's, silently overwriting session[:user] with
# nothing logged anywhere to explain it.
#
# A plain Rack middleware, not a Sinatra `before` filter: static assets
# (public_folder — every JS/CSS/font/vendor file) are served by
# Sinatra's own built-in static-file shortcut, which bypasses route and
# filter dispatch entirely but is still wrapped by this, same as every
# other response — each one was independently capable of winning the
# clobbering race a `before` filter alone left uncovered.
class SkipSessionWrite
WRITE_PATHS = %w[/login /auth/callback /logout].freeze
def initialize(app)
@app = app
end
def call(env)
env["rack.session.options"][:skip] = true unless WRITE_PATHS.include?(env["PATH_INFO"])
@app.call(env)
end
end
class App < Sinatra::Base class App < Sinatra::Base
set :public_folder, File.join(__dir__, "public") set :public_folder, File.join(__dir__, "public")
set :views, File.join(__dir__, "views") set :views, File.join(__dir__, "views")
@@ -33,6 +67,7 @@ module Maptool
# don't survive a restart there, which is fine outside production. # don't survive a restart there, which is fine outside production.
set :sessions, httponly: true, secure: production?, same_site: :lax, set :sessions, httponly: true, secure: production?, same_site: :lax,
secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) } secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) }
use SkipSessionWrite
# Cache-busts /js and /css requests: browsers (and a caching proxy in # Cache-busts /js and /css requests: browsers (and a caching proxy in
# front of production, see docs/deployment.adoc) hang onto static # front of production, see docs/deployment.adoc) hang onto static
@@ -277,6 +312,42 @@ module Maptool
json PmtilesBuilder.offline_status json PmtilesBuilder.offline_status
end end
# Deletes offline.pmtiles plus its whole per-country download cache —
# a manual "reclaim the space"/"start over" action, distinct from
# unticking countries in the region picker (which only changes what a
# future build covers, doesn't free anything already on disk).
delete "/api/offline-tiles" do
require_login!
begin
PmtilesBuilder.delete_offline_data(DATA_DIR)
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json ok: true
end
# What's actually in the per-country download cache right now (not the
# same as offline.pmtiles' own meta.sources — see cached_extracts) —
# read-only, same tier as GET /api/offline-regions above, so the region
# picker can show a size + delete affordance next to each cached
# country regardless of whether it's still selected or already built.
get "/api/offline-tiles/cache" do
json extracts: PmtilesBuilder.cached_extracts(DATA_DIR)
end
# Deletes one country's cached Geofabrik extract only — e.g. to force a
# clean re-download of just that one, or reclaim space for a country
# that's no longer selected (the cache otherwise keeps it indefinitely).
delete "/api/offline-tiles/cache/:code" do
require_login!
begin
PmtilesBuilder.delete_cached_extract(DATA_DIR, params[:code])
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json ok: true
end
# Same optional-local-file story as offline.pmtiles above — see # Same optional-local-file story as offline.pmtiles above — see
# docs/development.adoc for how to generate one, until then hillshade # docs/development.adoc for how to generate one, until then hillshade
# just stays unavailable while the offline basemap is active. # just stays unavailable while the offline basemap is active.
@@ -309,6 +380,45 @@ module Maptool
json PmtilesBuilder.terrain_status json PmtilesBuilder.terrain_status
end end
# Same reasoning as DELETE /api/offline-tiles above.
delete "/api/terrain-tiles" do
require_login!
begin
PmtilesBuilder.delete_terrain_data(DATA_DIR)
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json ok: true
end
# Generated as a step inside POST /api/offline-tiles/build (see
# PmtilesBuilder.extract_hiking_trails) rather than its own separate
# build — it's derived from the exact same per-country .osm.pbf cache
# offline.pmtiles itself already downloads, so there's nothing of its
# own to trigger; DELETE /api/offline-tiles removes it too. One file
# per country (not one combined file) so a viewer only ever fetches
# the country they actually pick in the Hiking trails overlay's own
# checklist (public/js/hiking_trails.js), not every selected country's
# trails at once — `countries` lists which ones exist to build that
# checklist from.
get "/api/hiking-trails" do
json countries: PmtilesBuilder.hiking_trails_countries(DATA_DIR)
end
# Fetched client-side only for a country the viewer actually ticks in
# the Hiking trails checklist, not on page load. :code is constrained
# to exactly what hiking_trails_countries can ever return (two
# uppercase letters) — otherwise this would accept an arbitrary
# ".."-laden path segment.
get "/tiles/hiking_trails/:code.geojson" do
halt 404 unless params[:code].match?(/\A[A-Z]{2}\z/)
path = PmtilesBuilder.hiking_trails_country_path(DATA_DIR, params[:code])
halt 404 unless File.exist?(path)
send_file path, type: "application/geo+json"
end
# Pinged once per page load (public/js/map.js) — url is a same-origin # Pinged once per page load (public/js/map.js) — url is a same-origin
# path (not kiwix-serve's own address, which the browser can't reach # path (not kiwix-serve's own address, which the browser can't reach
# directly behind the reverse proxy in front of this app), proxied by # directly behind the reverse proxy in front of this app), proxied by
@@ -633,10 +743,8 @@ module Maptool
halt 502, json(error: "track has no positions") if coordinates.empty? halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last last = points.last
json( features = [
type: "FeatureCollection", { type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: { "kind" => "actual" } },
features: [
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
{ {
type: "Feature", type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last }, geometry: { type: "Point", coordinates: coordinates.last },
@@ -650,7 +758,21 @@ module Maptool
), ),
}, },
] ]
)
# Only present when the activity was started from a saved Garmin
# course (see LiveTrackClient#fetch) — plotted separately from the
# actual track ("kind" distinguishes the two LineStrings) since it's
# the planned route, not where the person has actually been.
course_coordinates = result.course&.[]("geoPoints")&.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
if course_coordinates&.any?
features << {
type: "Feature",
geometry: { type: "LineString", coordinates: course_coordinates },
properties: { "kind" => "planned", "name" => result.course["name"] },
}
end
json(type: "FeatureCollection", features: features)
end end
get "/api/gnss/position" do get "/api/gnss/position" do
+12 -2
View File
@@ -65,7 +65,12 @@ services:
# happens outside this mount, in the container's own filesystem — a # happens outside this mount, in the container's own filesystem — a
# large country's .osm.pbf plus Planetiler's working set can be # 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. # several GB, worth knowing if the host is ever short on space there.
- ./data:/app/data # :Z (not :z — this path is only ever mounted into this one
# container) relabels the host directory for SELinux, since Podman
# on an SELinux-enforcing host otherwise denies the container write
# access to a bind-mounted directory regardless of Unix permissions
# — harmless/ignored on a host without SELinux.
- ./data:/app/data:Z
# Fakes a GNSS fix (a loop around Bratislava, see # Fakes a GNSS fix (a loop around Bratislava, see
# bin/gnss_simulator.rb/docs/development.adoc) since there's no real # bin/gnss_simulator.rb/docs/development.adoc) since there's no real
@@ -118,4 +123,9 @@ services:
# ever talks to this container directly. # ever talks to this container directly.
command: ["--urlRootLocation=/kiwix", "/data/wikipedia.zim"] command: ["--urlRootLocation=/kiwix", "/data/wikipedia.zim"]
volumes: volumes:
- ./data/kiwix/wikipedia.zim:/data/wikipedia.zim # :Z, same SELinux reasoning as maptool's own ./data mount above.
- ./data/kiwix/wikipedia.zim:/data/wikipedia.zim:Z
networks:
default:
enable_ipv6: true
+32 -10
View File
@@ -7,11 +7,21 @@ MapTool deploys as a Podman container (via `podman compose`, driven by a
the Gitea Actions runners run on. Two separate runners are registered the Gitea Actions runners run on. Two separate runners are registered
against the Gitea instance: against the Gitea instance:
* The existing Docker-based runner (`gitea/runner:3`), labels * The existing Docker-based runner (`gitea/runner:3`), label
`ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04` — runs the `check` job `brick-build` — runs the `check` and `renovate` jobs in a fresh,
in a fresh, isolated container per job, same as before. isolated container per job. Every label on this runner (including
`ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04`, still registered but no
longer what `check`/`renovate` pin to) now maps to Gitea's own
official runner image (`docker.gitea.com/runner-images:ubuntu-latest`,
has Node out of the box) — an earlier `opensuse/leap:16.0` mapping for
`brick-build` specifically had no Node, which every step in
`check`/`renovate` needs (they're JS-based Actions), and briefly broke
both jobs (2026-09-12) until reverted to the shared `ubuntu-latest`
label; now that the image itself is fixed, both are pinned back to
`brick-build` — this machine specifically, not whichever runner
happens to answer to the shared label (e.g. "Gitea Runner on Cube").
* A dedicated native runner, installed directly on the host (no Docker * A dedicated native runner, installed directly on the host (no Docker
around it), label `cube-host` — runs only the `deploy` job. around it), label `brick-host` — runs only the `deploy` job.
They're separate because the Docker-based runner's jobs (whether in They're separate because the Docker-based runner's jobs (whether in
its usual isolated per-job containers, or tried directly inside its its usual isolated per-job containers, or tried directly inside its
@@ -55,6 +65,13 @@ expect this to flip the other way, `/srv/docker` symlinked to
only holds what's checked into this public repo, and gets wiped and only holds what's checked into this public repo, and gets wiped and
replaced by the checkout sync on every deploy same as the rest of replaced by the checkout sync on every deploy same as the rest of
`build/`. `build/`.
`data/` (and the kiwix ZIM path) carries a `:Z` SELinux relabel in
`docker-compose.yaml`'s bind mounts — without it, Podman on an
SELinux-enforcing host denies the container write access to the
bind-mounted directory regardless of its Unix permissions (surfaces
as a plain `Permission denied` in the app's own logs, nothing
SELinux-labeled unless you go looking in `audit.log`); harmless/
ignored on a host where SELinux isn't enforcing.
* `.env` — regenerated every deploy (see below), lives here rather * `.env` — regenerated every deploy (see below), lives here rather
than in `build/` for the same reason as the symlink: `docker than in `build/` for the same reason as the symlink: `docker
compose`/`podman compose` auto-load `.env` from the compose file's compose`/`podman compose` auto-load `.env` from the compose file's
@@ -84,7 +101,7 @@ label:
---- ----
runner: runner:
labels: labels:
- "cube-host" - "brick-host"
---- ----
The `host` scheme means jobs run directly via the runner process's own The `host` scheme means jobs run directly via the runner process's own
@@ -195,7 +212,7 @@ const PROCESSED_LABEL = "livetrack-processed";
function checkLiveTrackEmail() { function checkLiveTrackEmail() {
const label = GmailApp.getUserLabelByName(PROCESSED_LABEL) || GmailApp.createLabel(PROCESSED_LABEL); const label = GmailApp.getUserLabelByName(PROCESSED_LABEL) || GmailApp.createLabel(PROCESSED_LABEL);
const threads = GmailApp.search(`from:noreply@garmin.com subject:LiveTrack -label:${PROCESSED_LABEL}`); const threads = GmailApp.search(`from:noreply@garmin.com -label:${PROCESSED_LABEL}`);
threads.forEach(thread => { threads.forEach(thread => {
// getBody() (raw HTML), not getPlainBody(): the email has no text/plain // getBody() (raw HTML), not getPlainBody(): the email has no text/plain
@@ -218,9 +235,14 @@ function checkLiveTrackEmail() {
The Gmail label (rather than marking the message read/unread) tracks which The Gmail label (rather than marking the message read/unread) tracks which
threads are already handled without touching the user's own inbox state. threads are already handled without touching the user's own inbox state.
`subject:LiveTrack` matches regardless of the account's language — Garmin No `subject:LiveTrack` filter on the search — despite Gmail's own
keeps "LiveTrack" as-is in the subject even in a localized substring-match documentation for `subject:`, that filter empirically
("Sledujte LiveTrack používateľa ...") one. returned zero threads against a real, currently-arriving LiveTrack email
whose subject does contain the word ("Sledujte LiveTrack používateľa
...", confirmed 2026-09-20), so it's left out entirely — the regex match
below is what actually narrows this down to real LiveTrack emails with a
usable session link, `from:noreply@garmin.com` alone is deliberately
broad.
Set the trigger from the editor: clock icon on the left (Triggers) → *Add Set the trigger from the editor: clock icon on the left (Triggers) → *Add
Trigger* → function `checkLiveTrackEmail`, time-driven, "Minutes timer", Trigger* → function `checkLiveTrackEmail`, time-driven, "Minutes timer",
@@ -237,7 +259,7 @@ webhook simply won't receive that session; nothing breaks.
== CI deploy job == CI deploy job
`.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only `.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only
on a push to `main`, with `runs-on: cube-host`: on a push to `main`, with `runs-on: brick-host`:
. Checks out the repo with plain `git` (`fetch` + `checkout`, . Checks out the repo with plain `git` (`fetch` + `checkout`,
authenticated via a one-off `http.extraHeader`) rather than authenticated via a one-off `http.extraHeader`) rather than
+112 -12
View File
@@ -71,18 +71,91 @@ and similar, varying by region):
[source,shell] [source,shell]
---- ----
pmtiles extract https://download.mapterhorn.com/planet.pmtiles planet-extract.pmtiles --bbox=west,south,east,north --maxzoom=15 pmtiles extract https://download.mapterhorn.com/planet.pmtiles planet-extract.pmtiles --bbox=west,south,east,north --maxzoom=13 --overfetch=0
# only if a regional chunk covers the bbox too (see download_urls.json) — # only if a regional chunk covers the bbox too (see download_urls.json) —
# most bboxes hit exactly one, a bbox straddling a chunk boundary hits two: # most bboxes hit exactly one, a bbox straddling a chunk boundary hits two:
pmtiles extract https://download.mapterhorn.com/<chunk>.pmtiles chunk-extract.pmtiles --bbox=west,south,east,north --maxzoom=15 pmtiles extract https://download.mapterhorn.com/<chunk>.pmtiles chunk-extract.pmtiles --bbox=west,south,east,north --maxzoom=13 --overfetch=0
pmtiles merge planet-extract.pmtiles chunk-extract.pmtiles data/terrain.pmtiles pmtiles merge planet-extract.pmtiles chunk-extract.pmtiles data/terrain.pmtiles
---- ----
`--overfetch=0`: the default (0.05) merges nearby byte ranges into fewer,
bigger requests to save round-trips — for `planet.pmtiles` (global z0-12,
tens of GB) over a bbox spanning several countries this can collapse the
whole extract into a *single* multi-GB request with no way to resume after
a mid-transfer HTTP/2 stream reset. `--overfetch=0` trades that one
fragile mega-request for many small ones instead (spread over
`--download-threads`, default 4) — slower end to end, but a dropped
connection only loses one small request's worth of progress, not the
whole extract.
`--maxzoom=13`: raster DEM tiles don't compress away like vector ones do
(every tile is a real image, even over flat/empty terrain), so each extra
zoom level roughly quadruples storage — the app's own build
(`PmtilesBuilder::TERRAIN_MAX_ZOOM`) caps at 13, each regional chunk's
own native minzoom, rather than the online hillshade layer's z15. A
12-country selection at z15 was headed well past 181GB; z13 is still
plenty for the hillshade toggle and the planned slope readout.
Tiles are 512px WebP, not the 256px PNG this app's *online* Terrarium Tiles are 512px WebP, not the 256px PNG this app's *online* Terrarium
source uses — `public/js/dem.js`'s own point-elevation lookups source uses — `public/js/dem.js`'s own point-elevation lookups
(`shadow.js`'s terrain-shadow overlay) read the actual decoded tile (`shadow.js`'s terrain-shadow overlay) read the actual decoded tile
size rather than assuming one, so this isn't a problem either way. size rather than assuming one, so this isn't a problem either way.
== Offline hiking trails
The sidebar's "Hiking trails" overlay works online out of the box
(waymarkedtrails.org raster tiles, `public/js/hiking_trails.js`) —
clicking a trail fires a one-off Overpass query for its name/colour/
network/distance. For it to work offline too, one `data/hiking_trails/
<CODE>.geojson` per country is generated automatically as the last step
of every `offline.pmtiles` build (`Maptool::HikingTrails`,
`lib/maptool/hiking_trails.rb`), straight from the *same* per-country
`.osm.pbf` files already downloaded for `offline.pmtiles` itself — no
separate download, no third-party service:
[source,shell]
----
osmium tags-filter country.osm.pbf r/route=hiking -o hiking.osm.pbf
osmium add-locations-to-ways hiking.osm.pbf -o hiking-located.osm.pbf
osmium cat hiking-located.osm.pbf -f opl,locations_on_ways=true,add_metadata=false -o hiking.opl
----
`add-locations-to-ways` matters: a plain Geofabrik PBF's ways only carry
bare node *references*, not coordinates, so skipping this step silently
produces ways with empty x/y in the OPL output and every route gets
dropped for having no usable geometry — not an error, just wrong output
(verified against a real extract before this was caught). No general OSM
tool stitches a route relation's member ways into one line for you
(routes are a semantic grouping, not a geometry type), so
`HikingTrails.parse_opl` does that walk itself — the same thing
waymarkedtrails.org and this app's own online Overpass click handler
already do.
Best-effort per country: an extraction problem on one country's PBF logs
a warning and is skipped rather than failing the whole `offline.pmtiles`
build, or even the other countries in the same selection, over what's a
bonus overlay, not the core map (`PmtilesBuilder.extract_hiking_trails`).
Cached the same way `geofabrik_cache` is (skipped once a country's own
extract is already newer than its source `.osm.pbf`), so growing the
region selection by one country only extracts that one. Coordinates are
rounded to 5 decimal places (~1m, plenty for a rendered line) — Slovakia
alone is ~3000 routes, ~30MB unrounded.
One file per country rather than one combined file: `GET /api/hiking-trails`
lists which country codes have an extract (`countries: [...]`), and the
Overlays panel's "Hiking trails" item turns that into a checklist —
each ticked country fetches its own `GET /tiles/hiking_trails/<CODE>.geojson`
only then, not on page load and not for countries never ticked. Offline
click hit-tests the loaded vector layer(s) directly
(`queryRenderedFeatures`) instead of querying Overpass, so it resolves
instantly with no network at all. `rake hiking:trails[pbf,out]` runs the
extraction by hand against one `.osm.pbf` for testing.
`DELETE /api/offline-tiles` removes the whole `data/hiking_trails/`
directory along with `offline.pmtiles` and the Geofabrik cache — there's
no separate delete/rebuild route for it, since it's not an independent
build.
== In-app regeneration == In-app regeneration
A logged-in user can also generate both files above from the app A logged-in user can also generate both files above from the app
@@ -94,15 +167,20 @@ Ticking countries and clicking "Apply changes" saves the selection
(`data/offline_regions.json`, an array of ISO A2 codes) and rebuilds (`data/offline_regions.json`, an array of ISO A2 codes) and rebuilds
both files to cover it. both files to cover it.
There's no standalone per-country file to individually delete — There's no standalone per-country *built* file — `offline.pmtiles`/
`offline.pmtiles`/`terrain.pmtiles` are always one combined extract for `terrain.pmtiles` are always one combined extract for the *whole
the *whole current selection*. "Remove a country" and "refresh a current selection*. "Remove a country" is just "change the selection,
country" are both just "change the selection, then Apply" (unticking a then Apply" (unticking a country and applying rebuilds without it).
country and applying rebuilds without it; nothing to separately terrain.pmtiles re-extracts its selection from scratch on
delete). Every Apply re-extracts the entire selection from scratch, not every Apply — fine given that extraction is byte-range requests
just what changed — fine given extraction is fast (byte-range requests against a remote archive, not full downloads. offline.pmtiles, by
against remote archives, not per-tile fetches), but worth revisiting if contrast, downloads a whole country's `.osm.pbf` from Geofabrik (can be
someone selects half of Europe at once and finds it slow. multi-GB), so it keeps a per-country cache under
`data/geofabrik_cache/` instead of re-fetching on every Apply — a
country already cached is only re-downloaded if Geofabrik's own
`Last-Modified` for it has moved on since (checked with a cheap `HEAD`,
see `PmtilesBuilder.cached_extract`), so growing or shrinking the
selection only touches whatever's actually new to it.
This wraps exactly the manual workflows documented above This wraps exactly the manual workflows documented above
(`lib/maptool/pmtiles_builder.rb`). offline.pmtiles downloads each (`lib/maptool/pmtiles_builder.rb`). offline.pmtiles downloads each
@@ -128,7 +206,29 @@ the current selection, `POST` to change it, then `POST
/api/offline-tiles/build` / `/api/terrain-tiles/build`, polled via the /api/offline-tiles/build` / `/api/terrain-tiles/build`, polled via the
matching `*/build-status` route) — only one of each kind at a time; matching `*/build-status` route) — only one of each kind at a time;
starting a second while one's running gets a 409, as does starting starting a second while one's running gets a 409, as does starting
either with nothing selected yet. either with nothing selected yet. While running, `build-status` also
includes a `step` string (e.g. `"fetching PL (3/4) — done: SK, CZ —
pending: HU"`, or `"tiling with planetiler"`) — see
`PmtilesBuilder.run_job`'s `report_step`.
`DELETE /api/offline-tiles` removes offline.pmtiles, its sidecar meta,
and the whole per-country download cache (`data/geofabrik_cache/`) —
`DELETE /api/terrain-tiles` does the same for terrain.pmtiles. Both are
a manual "reclaim the space" / "start over" action (409 while that
kind's build is running), separate from the region picker's Apply,
which only changes what a *future* build covers and never frees
anything already on disk. `DELETE
/api/offline-tiles/cache/:code` removes just one country's cached
extract, e.g. to force a clean re-download of it without touching the
rest of the cache. `GET /api/offline-tiles/cache` lists what's
actually in that cache right now (code/size/Last-Modified per file) —
not the same thing as offline.pmtiles' own `meta.sources`, which only
reflects the last successful build: a country can be cached without
ever having been built (ticked, not yet Applied) or built from without
still being selected (unticked since). The region picker shows this
next to each country's checkbox, with its own delete button, rather
than only offering "delete everything" on the whole offline.pmtiles
row.
Each successful build also writes a small sidecar (`offline.pmtiles. Each successful build also writes a small sidecar (`offline.pmtiles.
meta.json` — each source country's code/Geofabrik slug/Last-Modified; meta.json` — each source country's code/Geofabrik slug/Last-Modified;
+44
View File
@@ -286,6 +286,50 @@ own work (a map viewer, not a publisher of NPS-branded content):
resource — included here only because it was shared alongside the resource — included here only because it was shared alongside the
typography notes above. typography notes above.
== Arrowhead (shield) logo colors
Unrelated to anything this app renders — noted here only because it came up
in conversation (wanting exact colors to 3D-print the NPS arrowhead) and
this doc is already the dumping ground for NPS-ecosystem color/graphic-identity
facts. Per NPS's own graphic identity standards (Harpers Ferry Center), the
arrowhead uses three official Pantone spot colors plus white:
[cols="2,2,1,1"]
|===
| Element | Pantone | Hex (converted) | Nearest RAL Classic
| Mountain
| Dark Rust, PMS 1615 C
| `#8b4720`
| RAL 2013 Pearl orange (`#923E25`)
| Sky
| Medium Rust, PMS 1605 C
| `#a65523`
| RAL 8023 Orange brown (`#A45729`)
| Trees / grass
| Dark Green, PMS 553 C
| `#284734`
| RAL 6012 Black green (`#2F3D3A`)
| Bison, lake, snowfield, type
| White
| `#ffffff`
| RAL 9016 Traffic white (`#F1F1EA`) — RAL has no pure white
|===
Hex values are third-party Pantone→RGB conversions, not something NPS
publishes directly (their style guide only specifies PMS codes). RAL codes
are the closest match by RGB distance against the RAL Classic chart — same
caveat, not an official NPS-endorsed mapping, just the nearest available
code for filament/paint shopping. Source: NPS Graphic Identity and Style
Guides (https://www.nps.gov/subjects/hfc/nps-graphic-identity-and-style-guides.htm),
FAQs About Arrowhead Artwork
(https://www.nps.gov/subjects/hfc/faqs-about-arrowhead-artwork.htm). Use of
the arrowhead symbol itself is controlled by law/regulation/policy — see
the FAQ page before printing/distributing anything bearing it.
== NPMap4 (the older, Leaflet-based generation) == NPMap4 (the older, Leaflet-based generation)
https://www.nps.gov/lib/npmap.js/4.2.1/ — predecessor to npmap5, https://www.nps.gov/lib/npmap.js/4.2.1/ — predecessor to npmap5,
+16 -2
View File
@@ -51,6 +51,20 @@ overview's polar pan limit, but MapLibre 6's `Map` no longer exposes
`.transform` at all — guarded to skip that (cosmetic) adjustment `.transform` at all — guarded to skip that (cosmetic) adjustment
instead of throwing when it's absent. instead of throwing when it's absent.
Also vendored under `public/vendor/` — MIT-licensed, attribution
required (`LICENSE` included alongside):
[cols="1,1,2"]
|===
| Library | Version | Source
| maplibre-gl-lanes (offline hiking trails, `public/js/hiking_trails.js`
— trails sharing a path drawn as parallel lanes); its `dist/*.mjs`
build, minus the trailing `sourceMappingURL` comment
| 1.3.0
| https://github.com/c0nsumer/maplibre-gl-lanes
|===
Also vendored under `public/vendor/` — MIT-licensed (© 2013 Panayiotis Also vendored under `public/vendor/` — MIT-licensed (© 2013 Panayiotis
Lipiridis), attribution required (this notice satisfies that): Lipiridis), attribution required (this notice satisfies that):
@@ -134,7 +148,7 @@ terrain.pmtiles regeneration.
| https://github.com/onthegomap/planetiler | https://github.com/onthegomap/planetiler
| osmium-tool (`osmium`, installed via the `osmium-tool` Debian package) | osmium-tool (`osmium`, installed via the `osmium-tool` Debian package)
| (Debian bookworm's packaged version) | (Debian trixie's packaged version)
| GPL-3.0 | GPL-3.0
| https://github.com/osmcode/osmium-tool | https://github.com/osmcode/osmium-tool
|=== |===
@@ -144,5 +158,5 @@ To update a vendored library: download its package (e.g.
`public/vendor/<name>/`, and update the version in this file. To `public/vendor/<name>/`, and update the version in this file. To
update go-pmtiles or Planetiler, bump `PMTILES_VERSION`/ update go-pmtiles or Planetiler, bump `PMTILES_VERSION`/
`PLANETILER_VERSION` in the `Dockerfile` and the version here — `PLANETILER_VERSION` in the `Dockerfile` and the version here —
osmium-tool tracks whatever version Debian bookworm currently packages, osmium-tool tracks whatever version Debian trixie currently packages,
nothing to pin on this side. nothing to pin on this side.
+181
View File
@@ -0,0 +1,181 @@
require "open3"
require "tmpdir"
require "json"
require_relative "track_stats"
module Maptool
# Prototype: extracts route=hiking relations (name/colour/network/operator,
# full member-way geometry) straight out of a Geofabrik .osm.pbf — the same
# file PmtilesBuilder already downloads per country for offline.pmtiles, so
# this needs no separate download or third-party service. Three osmium
# subprocess steps do the heavy lifting (osmium is already a server
# dependency, see PmtilesBuilder/docs/development.adoc):
#
# 1. `osmium tags-filter FILE r/route=hiking -o hiking.osm.pbf` — pulls
# out just the hiking relations *and* (by default, no -R) every way/
# node they reference, so the output is small and self-contained.
# 2. `osmium add-locations-to-ways` — a plain Geofabrik PBF's ways only
# carry bare node *references* (ids), not coordinates; this resolves
# each way's node ids against the node objects in the same file and
# rewrites the way with real coordinates attached. Verified against a
# real extract (Macedonia) that skipping this step silently produces
# ways with empty x/y (`n123xy`, no numbers) in the OPL output below —
# not an error, just wrong output, so easy to miss without checking.
# 3. `osmium cat hiking.osm.pbf -f opl,locations_on_ways=true,add_metadata=false`
# — OPL is a plain-text, one-object-per-line format (see
# https://osmcode.org/opl-file-format/); locations_on_ways (now that
# step 2 actually populated them) embeds each way's node coordinates
# directly in its own line, so no separate node-id -> [lon,lat] table
# needs building, just two passes over text.
#
# No general OSM tool stitches a route relation's member ways into one
# line for you (routes are a semantic grouping, not a geometry type) —
# every hiking-trail renderer, including this app's own Overpass-based
# click handler (public/js/hiking_trails.js) and waymarkedtrails.org
# itself, does this same member-way walk by hand.
module HikingTrails
class ExtractError < StandardError; end
ROUTE_TAGS = %w[name description ref colour network operator].freeze
def self.osmium_bin
ENV.fetch("OSMIUM_BIN", "osmium")
end
# pbf_path: a Geofabrik-style .osm.pbf, e.g. one of
# PmtilesBuilder.cached_extracts' own files. Returns a GeoJSON
# FeatureCollection (MultiLineString per relation, one sub-line per
# member way — not one big concatenated LineString, since a relation's
# ways aren't always contiguous/ordered and jumping across a gap would
# draw a spurious straight connector).
def self.extract(pbf_path)
Dir.mktmpdir("maptool-hiking-trails") do |dir|
filtered = File.join(dir, "hiking.osm.pbf")
run!(osmium_bin, "tags-filter", pbf_path, "r/route=hiking", "-o", filtered)
located = File.join(dir, "hiking-located.osm.pbf")
run!(osmium_bin, "add-locations-to-ways", filtered, "-o", located)
opl_path = File.join(dir, "hiking.opl")
run!(osmium_bin, "cat", located, "-f", "opl,locations_on_ways=true,add_metadata=false", "-o", opl_path)
parse_opl(File.read(opl_path))
end
end
# Pure — no subprocess — so it's cheaply testable against a canned OPL
# fixture, same "keep the real logic testable without the real tool
# installed" reasoning OverpassPois.to_geojson already gives for staying
# separate from OverpassPois.fetch.
def self.parse_opl(text)
ways = {} # way id -> [[lon, lat], ...]
relations = [] # { tags:, members: [{type:, id:}, ...] }
text.each_line do |line|
fields = line.chomp.split(" ")
head = fields.first
next unless head
case head[0]
when "w"
ways[head[1..].to_i] = way_coordinates(fields)
when "r"
tags = tags_field(fields)
relations << { id: head[1..].to_i, tags: tags, members: members_field(fields) } if tags["route"] == "hiking"
end
end
features = relations.filter_map { |relation| relation_feature(relation, ways) }
{ "type" => "FeatureCollection", "features" => features }
end
def self.relation_feature(relation, ways)
lines = relation[:members]
.select { |member| member[:type] == "w" }
.filter_map { |member| ways[member[:id]] }
.select { |coords| coords.size >= 2 }
return nil if lines.empty?
distance = lines.sum { |coords| TrackStats.compute(coords)["distance_meters"] }
tags = relation[:tags]
properties = {
"id" => relation[:id],
"name" => tags["name"] || tags["description"],
"distance_meters" => distance.round(1),
}
ROUTE_TAGS.each { |key| properties[key] ||= tags[key] }
{
"type" => "Feature",
"properties" => properties.compact,
# Rounded to 5 decimal places (~1m) — full OSM float precision
# (~1cm) is pointless for a rendered line and roughly doubles the
# size of every coordinate pair once this many-country GeoJSON is
# actually served to a browser.
"geometry" => { "type" => "MultiLineString", "coordinates" => round_coordinates(lines) },
}
end
private_class_method :relation_feature
def self.round_coordinates(lines)
lines.map { |coords| coords.map { |lon, lat| [lon.round(5), lat.round(5)] } }
end
private_class_method :round_coordinates
def self.field(fields, letter)
fields.find { |f| f.start_with?(letter) }
end
private_class_method :field
def self.tags_field(fields)
raw = field(fields, "T")&.delete_prefix("T") || ""
return {} if raw.empty?
raw.split(",").to_h { |pair| pair.split("=", 2).map { |part| unescape(part.to_s) } }
end
private_class_method :tags_field
def self.members_field(fields)
raw = field(fields, "M")&.delete_prefix("M") || ""
return [] if raw.empty?
raw.split(",").map do |member|
{ type: member[0], id: member[1..].split("@", 2).first.to_i }
end
end
private_class_method :members_field
# "n123x14.123y49.456,n124x14.130y49.460,..." -> [[14.123, 49.456], ...].
# A member id with no embedded location (shouldn't happen with
# locations_on_ways=true and -R omitted, but a way with a dangling/
# missing node reference is possible in real-world data) is skipped
# rather than raising — one bad node shouldn't drop the whole route.
def self.way_coordinates(fields)
raw = field(fields, "N")&.delete_prefix("N") || ""
return [] if raw.empty?
raw.split(",").filter_map do |node_ref|
match = node_ref.match(/\An\d+x(?<lon>-?[\d.]+)y(?<lat>-?[\d.]+)\z/)
[match[:lon].to_f, match[:lat].to_f] if match
end
end
private_class_method :way_coordinates
# OPL escapes space/newline/comma/equals/@/% as %<hex>% (see
# https://osmcode.org/opl-file-format/#_encoding) — only tag keys/
# values and usernames can contain them; the field-letter prefixes and
# id/comma/@ separators we split on above are never themselves escaped.
def self.unescape(str)
str.gsub(/%([0-9a-fA-F]{1,6})%/) { [Regexp.last_match(1).to_i(16)].pack("U") }
end
private_class_method :unescape
def self.run!(*args)
stdout, stderr, status = Open3.capture3(*args)
raise ExtractError, "#{args.first} exited with status #{status.exitstatus}: #{[stdout, stderr].map(&:strip).reject(&:empty?).join("\n")}" unless status.success?
rescue Errno::ENOENT, Errno::EACCES => e
raise ExtractError, "could not run '#{args.first}' (#{e.message}) — is osmium-tool installed and on PATH?"
end
private_class_method :run!
end
end
+47 -7
View File
@@ -1,5 +1,6 @@
require "net/http" require "net/http"
require "json" require "json"
require "time"
module Maptool module Maptool
# Fetches a Garmin LiveTrack session's track and metadata. # Fetches a Garmin LiveTrack session's track and metadata.
@@ -18,7 +19,7 @@ module Maptool
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
Result = Struct.new(:points, :session, keyword_init: true) Result = Struct.new(:points, :session, :course, keyword_init: true)
def self.fetch(session_id, token) def self.fetch(session_id, token)
new.fetch(session_id, token) new.fetch(session_id, token)
@@ -29,6 +30,10 @@ module Maptool
# if that part wasn't found even though points were (seen in practice: # if that part wasn't found even though points were (seen in practice:
# session details are consistently present alongside points, but nothing # session details are consistently present alongside points, but nothing
# here depends on that holding forever). # here depends on that holding forever).
# `course`: the planned route (name/geoPoints), or nil — only present
# when the activity was actually started from a saved Garmin course
# (session["courseIds"] is how the page itself knows to fetch one; a
# plain unplanned activity has neither).
def fetch(session_id, token) def fetch(session_id, token)
payloads = parse_payloads(fetch_html(session_id, token)) payloads = parse_payloads(fetch_html(session_id, token))
@@ -36,18 +41,42 @@ module Maptool
raise FetchError, "no track points found (session invalid or expired?)" unless points raise FetchError, "no track points found (session invalid or expired?)" unless points
session = search(payloads) { |node| node if node["sessionName"] } session = search(payloads) { |node| node if node["sessionName"] }
# First of possibly several — a LiveTrack session only ever follows
# the one course it was started from.
course = search(payloads) { |node| node["courses"].first if node["courses"].is_a?(Array) && !node["courses"].empty? }
Result.new(points: clean(points), session: clean(session)) Result.new(points: clean(points), session: clean(session), course: clean(course))
end end
# Distance/duration/activity type come from the last track point (its # Distance/duration/activity type come from the last track point (its
# totals are cumulative for the whole session), name/type from the # totals are cumulative for the whole session), name/type from the
# session details — nil-safe throughout since either can be missing. # session details — nil-safe throughout since either can be missing.
# #
# "active": Garmin doesn't set a session's `end` until the activity is # "active": originally `session["end"].nil?`, on the assumption Garmin
# actually stopped, so its absence means the session is still recording # doesn't set `end` until the activity actually stops — disproven
# — inferred from the one (already-ended) session this was built # against a real still-recording session (2026-09-20): `end` was
# against, not verified against a session actually caught mid-activity. # already non-nil, set to exactly `start + 24h`, the share link's own
# expiry, not an actual end time. Two signals now, both verified live
# against that same session:
#
# * `end` itself, once it's actually in the past — watched the same
# session update it from that 24h placeholder to within a second of
# the last point's own time, immediately after the activity was
# saved on the watch. Authoritative and near-instant when present,
# so this is checked first.
# * failing that (still showing the future placeholder — an abandoned
# session never gets saved, so `end` never moves), recency of the
# last track point: Garmin posts one roughly every
# `postTrackPointFrequency` seconds (~15, observed) while genuinely
# still recording, so one older than ACTIVE_WITHIN means signal was
# lost a while ago (same "looks ended until it isn't" behavior
# Garmin's own LiveTrack page shows in that case).
#
# Both must agree the session is live — `end` already in the past
# means stopped even if the last point is only seconds old (exactly
# the save-immediately-after-the-last-point case above).
ACTIVE_WITHIN = 30 * 60 # seconds
def self.summarize(result) def self.summarize(result)
last = result.points.last || {} last = result.points.last || {}
session = result.session || {} session = result.session || {}
@@ -56,12 +85,23 @@ module Maptool
"activity_type" => last["activityType"], "activity_type" => last["activityType"],
"distance_meters" => last["totalDistanceMeters"], "distance_meters" => last["totalDistanceMeters"],
"duration_secs" => last["totalDurationSecs"], "duration_secs" => last["totalDurationSecs"],
"active" => session["end"].nil?, "active" => active?(last["dateTime"], session["end"]),
"start" => session["start"], "start" => session["start"],
"end" => session["end"], "end" => session["end"],
} }
end end
def self.active?(last_point_time, session_end)
return false unless last_point_time
now = Time.now.utc
return false if session_end && Time.parse(session_end) <= now
Time.parse(last_point_time) > now - ACTIVE_WITHIN
rescue ArgumentError, TypeError
false
end
private private
# The RSC payload serializes an absent field as the literal string # The RSC payload serializes an absent field as the literal string
+26 -3
View File
@@ -12,7 +12,15 @@ module Maptool
module OverpassPois module OverpassPois
class FetchError < StandardError; end class FetchError < StandardError; end
OVERPASS_API = "https://overpass-api.de/api/interpreter".freeze # Same three public mirrors public/js/hiking_trails.js races — tried in
# turn here instead, falling through to the next on any failure: the
# shared free instances regularly 504/429 under load, and a plugin run
# is a one-off click, not worth threads to race them.
OVERPASS_APIS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
].freeze
# Each entry: an Overpass tag filter -> { label:, icon:, color: }, used # Each entry: an Overpass tag filter -> { label:, icon:, color: }, used
# both to build the query (unioning every filter) and to label/style # both to build the query (unioning every filter) and to label/style
@@ -53,8 +61,23 @@ module Maptool
# bbox: [south, west, north, east] (Overpass's own axis order). # bbox: [south, west, north, east] (Overpass's own axis order).
def self.fetch(bbox, timeout: 60) def self.fetch(bbox, timeout: 60)
query = build_query(bbox, timeout: timeout) query = build_query(bbox, timeout: timeout)
uri = URI(OVERPASS_API) errors = OVERPASS_APIS.map do |url|
response = Net::HTTP.post_form(uri, "data" => query) return post(URI(url), query, timeout)
rescue FetchError, Net::OpenTimeout, Net::ReadTimeout, SystemCallError, SocketError, OpenSSL::SSL::SSLError, JSON::ParserError => e
"#{URI(url).host}: #{e.message}"
end
raise FetchError, errors.join("; ")
end
def self.post(uri, query, timeout)
# overpass-api.de answers Net::HTTP's default "Ruby" User-Agent with
# a 406 (verified 2026-10-09) — any identifying UA gets through.
request = Net::HTTP::Post.new(uri, "User-Agent" => "maptool (Overpass camper POIs plugin)")
request.set_form_data("data" => query)
# read_timeout past the query's own [timeout:] — Net::HTTP's default
# 60s equals it, so a big viewport could hit Net::ReadTimeout just as
# Overpass was about to answer (a country-sized bbox measured ~57s).
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: timeout + 30) { |http| http.request(request) }
raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess) raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body) JSON.parse(response.body)
+7 -2
View File
@@ -65,8 +65,13 @@ module Maptool
south, west, north, east = bbox south, west, north, east = bbox
args = ["--south=#{south}", "--west=#{west}", "--north=#{north}", "--east=#{east}"] args = ["--south=#{south}", "--west=#{west}", "--north=#{north}", "--east=#{east}"]
stdout, _stderr, status = Open3.capture3(command, *args, chdir: plugin_dir) stdout, stderr, status = Open3.capture3(command, *args, chdir: plugin_dir)
raise RunError, "plugin exited with status #{status.exitstatus}" unless status.success? # First stderr line too — for a Ruby plugin that's the exception
# message (e.g. "...: unexpected response 406"), the only clue to why.
unless status.success?
reason = stderr.lines.first&.strip
raise RunError, ["plugin exited with status #{status.exitstatus}", reason].compact.reject(&:empty?).join(": ")
end
geojson = JSON.parse(stdout) geojson = JSON.parse(stdout)
raise RunError, "plugin did not print a GeoJSON FeatureCollection" unless geojson.is_a?(Hash) && geojson["type"] == "FeatureCollection" raise RunError, "plugin did not print a GeoJSON FeatureCollection" unless geojson.is_a?(Hash) && geojson["type"] == "FeatureCollection"
+314 -46
View File
@@ -4,6 +4,8 @@ require "uri"
require "json" require "json"
require "tmpdir" require "tmpdir"
require "fileutils" require "fileutils"
require "etc"
require_relative "hiking_trails"
module Maptool module Maptool
# Regenerates data/offline.pmtiles and data/terrain.pmtiles for the # Regenerates data/offline.pmtiles and data/terrain.pmtiles for the
@@ -87,11 +89,15 @@ module Maptool
# archive" story via go-pmtiles, unrelated to the basemap swap. # archive" story via go-pmtiles, unrelated to the basemap swap.
MAPTERHORN_MANIFEST_URL = "https://download.mapterhorn.com/download_urls.json" MAPTERHORN_MANIFEST_URL = "https://download.mapterhorn.com/download_urls.json"
MAPTERHORN_PLANET_NAME = "planet.pmtiles" MAPTERHORN_PLANET_NAME = "planet.pmtiles"
# Matches the online hillshade layer's own cap (public/js/overlays.js's # Deliberately *below* the online hillshade layer's own cap
# ONLINE_TERRAIN_TILES source, maxzoom: 15) — applied uniformly even # (public/js/overlays.js's ONLINE_TERRAIN_TILES source, maxzoom: 15) —
# though some Mapterhorn chunks natively offer more, so the offline # raster DEM tiles don't compress away like vector ones do, so every
# experience doesn't vary in detail from one region to the next. # extra zoom level roughly quadruples storage. z13 is each regional
TERRAIN_MAX_ZOOM = 15 # chunk's own native minzoom (see the comment above), so this is the
# cheapest useful cut: one raster level per chunk, not a 15-13=2-level
# pyramid on top of it. Bumped into a 181GB terrain.pmtiles for a
# 5-country selection before this was lowered — see docs/development.adoc.
TERRAIN_MAX_ZOOM = 13
OFFLINE_STATUS = { state: "idle" } OFFLINE_STATUS = { state: "idle" }
TERRAIN_STATUS = { state: "idle" } TERRAIN_STATUS = { state: "idle" }
@@ -179,17 +185,21 @@ module Maptool
# finishes — raises BuildError (the caller turns this into an error # finishes — raises BuildError (the caller turns this into an error
# response) if nothing's selected yet, or one's already running. # response) if nothing's selected yet, or one's already running.
# #
# download_extract exists as an injectable seam so tests can exercise # download_extract/fetch_last_modified exist as injectable seams so
# the rest of this (scratch dir, osmium/planetiler invocation, atomic # tests can exercise the rest of this (scratch dir, osmium/planetiler
# rename) without a real network call — same reasoning test_helper.rb # invocation, atomic rename, the per-country cache below) without a
# already gives for keeping /api/plugins route tests off the real # real network call — same reasoning test_helper.rb already gives for
# Overpass API. # keeping /api/plugins route tests off the real Overpass API.
def self.build_offline(data_dir:, download_extract: method(:download_extract)) def self.build_offline(data_dir:, download_extract: method(:download_extract), fetch_last_modified: method(:fetch_geofabrik_last_modified))
codes = selected_regions(data_dir) codes = selected_regions(data_dir)
raise BuildError, "select at least one region first" if codes.empty? raise BuildError, "select at least one region first" if codes.empty?
begin_run!(OFFLINE_MUTEX, OFFLINE_STATUS) begin_run!(OFFLINE_MUTEX, OFFLINE_STATUS)
Thread.new { run_job(OFFLINE_MUTEX, OFFLINE_STATUS) { extract_offline(codes, data_dir, download_extract) } } Thread.new do
run_job(OFFLINE_MUTEX, OFFLINE_STATUS) do |report_step|
extract_offline(codes, data_dir, download_extract, fetch_last_modified, report_step)
end
end
end end
# fetch_manifest: same injectable-seam reasoning as download_extract # fetch_manifest: same injectable-seam reasoning as download_extract
@@ -199,7 +209,11 @@ module Maptool
raise BuildError, "select at least one region first" if codes.empty? raise BuildError, "select at least one region first" if codes.empty?
begin_run!(TERRAIN_MUTEX, TERRAIN_STATUS) begin_run!(TERRAIN_MUTEX, TERRAIN_STATUS)
Thread.new { run_job(TERRAIN_MUTEX, TERRAIN_STATUS) { build_terrain_pmtiles(codes, data_dir, fetch_manifest) } } Thread.new do
run_job(TERRAIN_MUTEX, TERRAIN_STATUS) do |report_step|
build_terrain_pmtiles(codes, data_dir, fetch_manifest, report_step)
end
end
end end
def self.begin_run!(mutex, status) def self.begin_run!(mutex, status)
@@ -210,11 +224,19 @@ module Maptool
end end
private_class_method :begin_run! private_class_method :begin_run!
# Yields a report_step proc the build can call with a short
# human-readable description of what it's doing right now ("downloading
# SK (1/2)", "tiling with planetiler", ...) — surfaced as status[:step]
# so GET .../build-status can show progress beyond just "running since
# X" (see docs/development.adoc).
def self.run_job(mutex, status) def self.run_job(mutex, status)
yield report_step = ->(text) { mutex.synchronize { status[:step] = text } }
yield report_step
mutex.synchronize { status.merge!(state: "done", finished_at: Time.now.utc.iso8601).delete(:message) } mutex.synchronize { status.merge!(state: "done", finished_at: Time.now.utc.iso8601).delete(:message) }
rescue => e rescue => e
mutex.synchronize { status.merge!(state: "error", finished_at: Time.now.utc.iso8601, message: e.message) } mutex.synchronize { status.merge!(state: "error", finished_at: Time.now.utc.iso8601, message: e.message) }
ensure
mutex.synchronize { status.delete(:step) }
end end
private_class_method :run_job private_class_method :run_job
@@ -228,50 +250,190 @@ module Maptool
# tiles.openfreemap.org's Liberty style already expects, so # tiles.openfreemap.org's Liberty style already expects, so
# public/js/map.js's offlineStyle() can reuse ONLINE_STYLE_PATCH # public/js/map.js's offlineStyle() can reuse ONLINE_STYLE_PATCH
# unchanged instead of a separate protomaps-themes-base palette. # unchanged instead of a separate protomaps-themes-base palette.
def self.extract_offline(codes, data_dir, download_extract) def self.extract_offline(codes, data_dir, download_extract, fetch_last_modified, report_step)
scratch_dir = Dir.mktmpdir("maptool-offline") scratch_dir = Dir.mktmpdir("maptool-offline")
sources = codes.map do |code| sources = codes.each_with_index.map do |code, i|
slug = GEOFABRIK_SLUGS.fetch(code) { raise BuildError, "no Geofabrik source known for region #{code}" } slug = GEOFABRIK_SLUGS.fetch(code) { raise BuildError, "no Geofabrik source known for region #{code}" }
dest = File.join(scratch_dir, "#{code}.osm.pbf") report_step.call(progress_step("fetching #{code}", codes, i))
last_modified = download_extract.call(geofabrik_url(slug), dest) cached_extract(code, slug, data_dir, download_extract, fetch_last_modified)
{ "code" => code, "slug" => slug, "last_modified" => last_modified, "path" => dest }
end end
osm_path = osm_path =
if sources.size == 1 if sources.size == 1
sources.first["path"] sources.first["path"]
else else
report_step.call("merging with osmium")
merged_path = File.join(scratch_dir, "merged.osm.pbf") merged_path = File.join(scratch_dir, "merged.osm.pbf")
run_osmium!("merge", *sources.map { |s| s["path"] }, "-o", merged_path) run_osmium!("merge", *sources.map { |s| s["path"] }, "-o", merged_path)
merged_path merged_path
end end
tmp_path = File.join(data_dir, "offline.pmtiles.tmp") report_step.call("tiling with planetiler")
run_planetiler!("--osm-path=#{osm_path}", "--output=#{tmp_path}") # .tmp.pmtiles, not .pmtiles.tmp — Planetiler picks its output archive
# format (pmtiles vs mbtiles) from the file extension, so a ".tmp"
# suffix made it fail with "Unsupported format tmp" instead of
# actually tiling anything.
tmp_path = File.join(data_dir, "offline.tmp.pmtiles")
# --download: the OpenMapTiles profile also needs a few small auxiliary
# sources (water polygons, Natural Earth, lake centerlines) alongside
# the OSM extract itself — without this flag Planetiler expects them
# to already exist on disk and fails instead of fetching them. They
# land under Planetiler's own default "data/sources/", relative to
# this process's working directory (the app root, so in practice
# that's data_dir's own "sources" subdirectory) — fetched once,
# reused by every later build.
# --threads: Planetiler's own core-count autodetection can come up
# short in a container (cgroup/JVM detection quirks vary by runtime),
# leaving most of a many-core host idle — Etc.nprocessors asks the
# OS directly (sched_getaffinity on Linux), which correctly reflects
# whatever's actually usable by this process even under a container.
run_planetiler!("--osm-path=#{osm_path}", "--output=#{tmp_path}", "--download", "--threads=#{Etc.nprocessors}")
File.rename(tmp_path, File.join(data_dir, "offline.pmtiles")) File.rename(tmp_path, File.join(data_dir, "offline.pmtiles"))
File.write(offline_meta_path(data_dir), JSON.generate(sources: sources.map { |s| s.slice("code", "slug", "last_modified") })) File.write(offline_meta_path(data_dir), JSON.generate(sources: sources.map { |s| s.slice("code", "slug", "last_modified") }))
report_step.call("extracting hiking trails")
extract_hiking_trails(sources, data_dir)
ensure ensure
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir) FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path) File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
end end
private_class_method :extract_offline private_class_method :extract_offline
# One GeoJSON per country (data/hiking_trails/<CODE>.geojson), not one
# merged file — the frontend only ever needs to show a country at a
# time the viewer actually picks (see HikingTrailsControl's country
# checklist), and a combined multi-country file was needlessly making
# every viewer fetch every selected country's trails regardless.
#
# Runs over the same per-country .osm.pbf files `sources` above already
# downloaded/cached for offline.pmtiles itself — no separate download.
# Cached the same way geofabrik_cache is: skipped for a country whose
# extract is already newer than its source .osm.pbf, so growing the
# region selection by one country only extracts that one, not every
# already-covered country all over again.
#
# Best-effort per country: an extraction problem (a malformed PBF
# region, an osmium hiccup) shouldn't fail the whole offline.pmtiles
# build, or even the other countries in this same selection, over
# what's a bonus overlay, not the core map.
def self.extract_hiking_trails(sources, data_dir)
FileUtils.mkdir_p(hiking_trails_dir(data_dir))
sources.each do |source|
out_path = hiking_trails_country_path(data_dir, source["code"])
next if File.exist?(out_path) && File.mtime(out_path) >= File.mtime(source["path"])
tmp_path = "#{out_path}.tmp"
begin
File.write(tmp_path, JSON.generate(HikingTrails.extract(source["path"])))
File.rename(tmp_path, out_path)
rescue HikingTrails::ExtractError => e
File.delete(tmp_path) if File.exist?(tmp_path)
warn "hiking trails extraction failed for #{source["code"]}, skipping: #{e.message}"
end
end
end
private_class_method :extract_hiking_trails
def self.hiking_trails_dir(data_dir)
File.join(data_dir, "hiking_trails")
end
def self.hiking_trails_country_path(data_dir, code)
File.join(hiking_trails_dir(data_dir), "#{code}.geojson")
end
# Country codes with an already-extracted offline hiking-trails file,
# sorted — not necessarily the same as the *current* region selection
# (accumulates like geofabrik_cache does; a country deselected after
# being built once is left alone rather than swept up here).
def self.hiking_trails_countries(data_dir)
Dir.glob(File.join(hiking_trails_dir(data_dir), "*.geojson")).map { |path| File.basename(path, ".geojson") }.sort
end
# Renders a report_step string like "fetching PL (3/4) — done: SK, CZ —
# pending: HU" — so GET .../build-status shows not just "it's running"
# but which of the current build's sources is in flight and which are
# already behind/ahead of it.
def self.progress_step(action, labels, index)
done = labels[0...index]
pending = labels[(index + 1)..] || []
parts = ["#{action} (#{index + 1}/#{labels.size})"]
parts << "done: #{done.join(", ")}" unless done.empty?
parts << "pending: #{pending.join(", ")}" unless pending.empty?
parts.join(" — ")
end
private_class_method :progress_step
def self.geofabrik_url(slug) def self.geofabrik_url(slug)
"https://download.geofabrik.de/#{slug}-latest.osm.pbf" "https://download.geofabrik.de/#{slug}-latest.osm.pbf"
end end
private_class_method :geofabrik_url private_class_method :geofabrik_url
# Real network implementation of the download_extract seam above — # Per-country .osm.pbf cache, kept across builds under data_dir (not
# streams the extract to dest and returns its Last-Modified header # scratch_dir, which is wiped every run) — changing the region
# (free off the same GET, no separate HEAD needed) for the sidecar # selection re-downloads only the countries that are new to it, and
# meta / update-check below. # re-running the same selection re-downloads nothing unless Geofabrik's
def self.download_extract(url, dest) # own Last-Modified for that country moved on. A stamp file next to
uri = URI(url) # each cached extract records the Last-Modified it was fetched with;
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.get(uri) } # a HEAD check (cheap) decides whether the multi-GB GET (not cheap) is
raise BuildError, "could not download #{url} (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess) # skippable.
def self.cached_extract(code, slug, data_dir, download_extract, fetch_last_modified)
cache_dir = geofabrik_cache_dir(data_dir)
FileUtils.mkdir_p(cache_dir)
path = File.join(cache_dir, "#{code}.osm.pbf")
stamp_path = "#{path}.last_modified"
url = geofabrik_url(slug)
File.binwrite(dest, response.body) if File.exist?(path) && File.exist?(stamp_path)
response["last-modified"] current = begin
fetch_last_modified.call(url)
rescue BuildError
nil
end
return { "code" => code, "slug" => slug, "last_modified" => current, "path" => path } if current && current == File.read(stamp_path)
end
last_modified = download_extract.call(url, path)
File.write(stamp_path, last_modified.to_s)
{ "code" => code, "slug" => slug, "last_modified" => last_modified, "path" => path }
end
private_class_method :cached_extract
def self.geofabrik_cache_dir(data_dir)
File.join(data_dir, "geofabrik_cache")
end
private_class_method :geofabrik_cache_dir
# Real network implementation of the download_extract seam above —
# streams the extract to dest (some of these are multi-GB — Net::HTTP's
# plain #get buffers the whole response body in memory first, which
# read_body's block form avoids) and returns its Last-Modified header
# (free off the same GET, no separate HEAD needed) for the sidecar
# meta / update-check below. Geofabrik 302s (e.g. .../europe/austria
# redirecting to wherever actually serves it) — followed manually
# since request_get's block form doesn't do this itself.
def self.download_extract(url, dest, limit: 5)
raise BuildError, "too many redirects downloading #{url}" if limit.zero?
uri = URI(url)
last_modified = nil
redirect_to = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request_get(uri) do |response|
case response
when Net::HTTPSuccess
last_modified = response["last-modified"]
File.open(dest, "wb") { |file| response.read_body { |chunk| file.write(chunk) } }
when Net::HTTPRedirection
redirect_to = URI.join(url, response["location"]).to_s
else
raise BuildError, "could not download #{url} (HTTP #{response.code})"
end
end
end
return download_extract(redirect_to, dest, limit: limit - 1) if redirect_to
last_modified
end end
# Sidecar recording which Geofabrik sources (country code, slug, and # Sidecar recording which Geofabrik sources (country code, slug, and
@@ -309,12 +471,16 @@ module Maptool
nil nil
end end
def self.fetch_geofabrik_last_modified(url) def self.fetch_geofabrik_last_modified(url, limit: 5)
raise BuildError, "too many redirects reaching #{url}" if limit.zero?
uri = URI(url) uri = URI(url)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.head(uri) } 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) case response
when Net::HTTPSuccess then response["last-modified"]
response["last-modified"] when Net::HTTPRedirection then fetch_geofabrik_last_modified(URI.join(url, response["location"]).to_s, limit: limit - 1)
else raise BuildError, "could not reach #{url}"
end
end end
def self.offline_meta_path(data_dir) def self.offline_meta_path(data_dir)
@@ -322,6 +488,52 @@ module Maptool
end end
private_class_method :offline_meta_path private_class_method :offline_meta_path
# Deletes offline.pmtiles, its sidecar meta, and the whole per-country
# download cache (data/geofabrik_cache/) — "start over" for the offline
# basemap. The current selection (data/offline_regions.json) is left
# alone, so a following build just re-downloads it fresh.
def self.delete_offline_data(data_dir)
OFFLINE_MUTEX.synchronize { raise BuildError, "a build is already running" if OFFLINE_STATUS[:state] == "running" }
File.delete(File.join(data_dir, "offline.pmtiles")) if File.exist?(File.join(data_dir, "offline.pmtiles"))
File.delete(offline_meta_path(data_dir)) if File.exist?(offline_meta_path(data_dir))
FileUtils.remove_entry(hiking_trails_dir(data_dir)) if Dir.exist?(hiking_trails_dir(data_dir))
cache_dir = geofabrik_cache_dir(data_dir)
FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir)
end
# Deletes just one country's cached .osm.pbf (and its Last-Modified
# stamp) — e.g. to force a fresh download without touching the others,
# or to reclaim space for a country no longer selected.
def self.delete_cached_extract(data_dir, code)
OFFLINE_MUTEX.synchronize { raise BuildError, "a build is already running" if OFFLINE_STATUS[:state] == "running" }
cache_dir = geofabrik_cache_dir(data_dir)
path = File.join(cache_dir, "#{code}.osm.pbf")
File.delete(path) if File.exist?(path)
File.delete("#{path}.last_modified") if File.exist?("#{path}.last_modified")
end
# What's actually sitting in the per-country download cache right now —
# not the same thing as offline_meta's "sources", which only reflects
# whatever the *last successful build* used. A country can be cached
# without ever having been built (selected, then deselected again
# before Apply) or built from without still being selected, and this is
# what lets the region picker show/clear either case.
def self.cached_extracts(data_dir)
cache_dir = geofabrik_cache_dir(data_dir)
return [] unless Dir.exist?(cache_dir)
Dir.glob(File.join(cache_dir, "*.osm.pbf")).map do |path|
stamp_path = "#{path}.last_modified"
{
"code" => File.basename(path, ".osm.pbf"),
"size" => File.size(path),
"last_modified" => File.exist?(stamp_path) ? File.read(stamp_path) : nil,
}
end.sort_by { |s| s["code"] }
end
# --- terrain.pmtiles --- # --- terrain.pmtiles ---
# planet.pmtiles (z0-12, always) plus whichever regional chunks # planet.pmtiles (z0-12, always) plus whichever regional chunks
@@ -338,7 +550,8 @@ module Maptool
# border tiles (extraction copies whole tiles, it doesn't clip vector # border tiles (extraction copies whole tiles, it doesn't clip vector
# content to the polygon) — so this always does one extract per # content to the polygon) — so this always does one extract per
# *source archive*, never one per country. # *source archive*, never one per country.
def self.build_terrain_pmtiles(codes, data_dir, fetch_manifest) def self.build_terrain_pmtiles(codes, data_dir, fetch_manifest, report_step)
report_step.call("fetching mapterhorn manifest")
items = fetch_manifest.call items = fetch_manifest.call
planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME } planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME }
raise BuildError, "mapterhorn manifest is missing #{MAPTERHORN_PLANET_NAME}" unless planet raise BuildError, "mapterhorn manifest is missing #{MAPTERHORN_PLANET_NAME}" unless planet
@@ -351,16 +564,33 @@ module Maptool
chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) } chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) }
tmp_path = File.join(data_dir, "terrain.pmtiles.tmp") # .tmp.pmtiles, not .pmtiles.tmp — same reasoning as offline.pmtiles'
extracts = [planet, *chunks].each_with_index.map do |item, i| # own tmp_path above, in case go-pmtiles also derives format from
# the extension for its "merge" output.
tmp_path = File.join(data_dir, "terrain.tmp.pmtiles")
sources = [planet, *chunks]
labels = sources.map { |item| item["name"] }
extracts = sources.each_with_index.map do |item, i|
report_step.call(progress_step("extracting #{item["name"]}", labels, i))
extract_path = File.join(scratch_dir, "extract-#{i}.pmtiles") extract_path = File.join(scratch_dir, "extract-#{i}.pmtiles")
run_pmtiles!("extract", item["url"], extract_path, "--region=#{region_path}", "--maxzoom=#{TERRAIN_MAX_ZOOM}") # --overfetch=0: go-pmtiles' default (0.05) merges nearby byte
# ranges into fewer, bigger HTTP requests to save round-trips —
# for planet.pmtiles (global z0-12, tens of GB) covering a
# multi-country selection that collapsed the whole extract into
# a *single* ~60GB request, which then had no way to resume after
# an HTTP/2 stream reset 11 minutes in (see the "died again"
# 2026-09-12 build). Disabling merging trades that one fragile,
# unresumable mega-request for many small ones spread over
# --download-threads (default 4) — slower on a clean run, but no
# longer one dropped stream away from losing 20 minutes of transfer.
run_pmtiles!("extract", item["url"], extract_path, "--region=#{region_path}", "--maxzoom=#{TERRAIN_MAX_ZOOM}", "--overfetch=0")
extract_path extract_path
end end
if extracts.size == 1 if extracts.size == 1
File.rename(extracts.first, tmp_path) File.rename(extracts.first, tmp_path)
else else
report_step.call("merging with pmtiles")
run_pmtiles!("merge", *extracts, tmp_path) run_pmtiles!("merge", *extracts, tmp_path)
end end
File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles")) File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles"))
@@ -423,6 +653,17 @@ module Maptool
end end
private_class_method :terrain_meta_path private_class_method :terrain_meta_path
# Deletes terrain.pmtiles and its sidecar meta — same "start over"
# reasoning as delete_offline_data above. No download cache to clean up
# here: terrain.pmtiles' pmtiles-extract step reads its sources over
# HTTP range requests rather than downloading a whole archive first.
def self.delete_terrain_data(data_dir)
TERRAIN_MUTEX.synchronize { raise BuildError, "a build is already running" if TERRAIN_STATUS[:state] == "running" }
File.delete(File.join(data_dir, "terrain.pmtiles")) if File.exist?(File.join(data_dir, "terrain.pmtiles"))
File.delete(terrain_meta_path(data_dir)) if File.exist?(terrain_meta_path(data_dir))
end
# --- shared --- # --- shared ---
def self.read_json(path) def self.read_json(path)
@@ -450,25 +691,52 @@ module Maptool
ENV.fetch("PLANETILER_JAR", "/usr/local/lib/planetiler.jar") ENV.fetch("PLANETILER_JAR", "/usr/local/lib/planetiler.jar")
end end
def self.run_pmtiles!(*args) # go-pmtiles' extract logs one progress line per chunk fetched
_stdout, stderr, status = Open3.capture3(pmtiles_bin, *args) # ("fetching N dirs, M chunks, K requests") — fine on a terminal where
raise BuildError, "pmtiles exited with status #{status.exitstatus}: #{stderr.strip}" unless status.success? # it overwrites itself, but captured non-interactively that's one line
# per chunk, easily hundreds of thousands of characters for a big
# region. The actual failure is always at the end, so keep only the
# tail — same story for osmium/planetiler's own chatty stdout.
OUTPUT_TAIL_LINES = 40
# Some CLI tools (go-pmtiles included) write their actual error to
# stdout rather than stderr — combine both rather than risk raising a
# BuildError with nothing useful in it.
def self.command_output(stdout, stderr)
lines = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n").lines
return lines.join if lines.size <= OUTPUT_TAIL_LINES
omitted = lines.size - OUTPUT_TAIL_LINES
"(#{omitted} earlier lines omitted)\n#{lines.last(OUTPUT_TAIL_LINES).join}"
end
private_class_method :command_output
# extract/merge stream tens of GB over HTTP range requests; retry a
# transient failure (e.g. a mid-transfer HTTP/2 stream reset from the
# remote) a couple times before giving up, rather than forcing a full
# re-download on every hiccup.
def self.run_pmtiles!(*args, attempts: 3)
attempts.times do |i|
stdout, stderr, status = Open3.capture3(pmtiles_bin, *args)
return if status.success?
raise BuildError, "pmtiles exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" if i == attempts - 1
end
rescue Errno::ENOENT, Errno::EACCES => e rescue Errno::ENOENT, Errno::EACCES => e
raise BuildError, "could not run '#{pmtiles_bin}' (#{e.message}) — is go-pmtiles installed and on PATH? See docs/development.adoc." raise BuildError, "could not run '#{pmtiles_bin}' (#{e.message}) — is go-pmtiles installed and on PATH? See docs/development.adoc."
end end
private_class_method :run_pmtiles! private_class_method :run_pmtiles!
def self.run_osmium!(*args) def self.run_osmium!(*args)
_stdout, stderr, status = Open3.capture3(osmium_bin, *args) stdout, stderr, status = Open3.capture3(osmium_bin, *args)
raise BuildError, "osmium exited with status #{status.exitstatus}: #{stderr.strip}" unless status.success? raise BuildError, "osmium exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" unless status.success?
rescue Errno::ENOENT, Errno::EACCES => e 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." raise BuildError, "could not run '#{osmium_bin}' (#{e.message}) — is osmium-tool installed and on PATH? See docs/development.adoc."
end end
private_class_method :run_osmium! private_class_method :run_osmium!
def self.run_planetiler!(*args) def self.run_planetiler!(*args)
_stdout, stderr, status = Open3.capture3(java_bin, "-jar", planetiler_jar, *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? raise BuildError, "planetiler exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" unless status.success?
rescue Errno::ENOENT, Errno::EACCES => e 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." raise BuildError, "could not run '#{java_bin} -jar #{planetiler_jar}' (#{e.message}) — is Java and planetiler.jar installed? See docs/development.adoc."
end end
+169
View File
@@ -397,6 +397,68 @@ html, body {
visibility: visible; visibility: visible;
} }
/* Docked detail panel for a clicked hiking trail — replaces a map-anchored
Popup (which routinely covered up the very trail line it described) with
a fixed side panel, same "click info moves off the map" idea
track_profile.js already established for clicking a GPX track line
(TrackProfilePanel). .trail-panel__body gets the exact padding
.maplibregl-popup-content used (15px 10px) so routeHtml()'s own output —
built for that popup, including its header's canceling negative margin —
renders here unchanged; no separate panel-flavored template needed. */
.trail-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 300px;
max-width: 90vw;
background: #fff;
border-left: 1px solid #ddd;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.trail-panel__body {
height: 100%;
box-sizing: border-box;
overflow-y: auto;
padding: 15px 10px;
font-size: 13px;
}
.trail-panel__close {
position: absolute;
top: 6px;
right: 6px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
font-size: 18px;
line-height: 1;
padding: 2px 7px;
cursor: pointer;
}
/* First breakpoint in this file. Below it the same panel docks to the
bottom edge instead of the right one — a fixed-width side panel would
otherwise eat most of a phone-width map, the exact "popup dominates the
screen" problem this panel was meant to fix in the first place. */
@media (max-width: 640px) {
.trail-panel {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: auto;
max-width: none;
max-height: 50vh;
border-left: none;
border-top: 1px solid #ddd;
border-radius: 8px 8px 0 0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15);
}
}
.offline-data-panel__body { .offline-data-panel__body {
padding: 8px 12px; padding: 8px 12px;
} }
@@ -416,11 +478,20 @@ html, body {
border-radius: 4px; border-radius: 4px;
} }
.offline-data-panel__region-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.offline-data-panel__region-list label { .offline-data-panel__region-list label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 8px; padding: 4px 8px;
min-width: 0;
flex: 1;
cursor: pointer; cursor: pointer;
} }
@@ -428,6 +499,34 @@ html, body {
background: #f0f0f0; background: #f0f0f0;
} }
.offline-data-panel__cache-info {
display: flex;
align-items: center;
gap: 4px;
padding-right: 8px;
color: #888;
font-size: 11px;
white-space: nowrap;
}
.offline-data-panel__cache-delete {
border: none;
background: none;
padding: 0 2px;
color: #a33;
font-size: 11px;
cursor: pointer;
}
.offline-data-panel__cache-delete:hover {
text-decoration: underline;
}
.offline-data-panel__cache-delete:disabled {
opacity: 0.5;
cursor: default;
}
/* Same rounded-square badge ratio as icons.js's own MAP_MARKER_ICON /* Same rounded-square badge ratio as icons.js's own MAP_MARKER_ICON
(rx 5 of a 22-wide square) — a consistent frame rather than however (rx 5 of a 22-wide square) — a consistent frame rather than however
each flag's own artwork happens to fill an unbordered square. */ each flag's own artwork happens to fill an unbordered square. */
@@ -472,12 +571,47 @@ html, body {
border-top: 1px solid #eee; border-top: 1px solid #eee;
} }
.offline-data-panel__row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.offline-data-panel__delete {
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
padding: 2px 8px;
font: inherit;
font-size: 11px;
color: #a33;
cursor: pointer;
}
.offline-data-panel__delete:hover {
background: #fdecec;
}
.offline-data-panel__delete:disabled {
opacity: 0.5;
cursor: default;
}
.offline-data-panel__status { .offline-data-panel__status {
margin: 6px 0 0; margin: 6px 0 0;
color: #666; color: #666;
font-size: 12px; font-size: 12px;
} }
.offline-data-panel__status--error {
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
color: #b3261e;
}
.offline-data-panel__update-badge { .offline-data-panel__update-badge {
color: #b8860b; color: #b8860b;
} }
@@ -1260,6 +1394,17 @@ html, body {
user-select: none; user-select: none;
} }
.pitch-toggle-ctrl__button {
width: 29px;
height: 29px;
font-size: 11px;
font-weight: 600;
}
.pitch-toggle-ctrl__button.active {
background: #dbe9ff;
}
.gpx-popup__header { .gpx-popup__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1271,6 +1416,14 @@ html, body {
color: #fff; color: #fff;
} }
.hiking-trail-popup__swatch {
flex-shrink: 0;
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.6);
}
.gpx-popup__header-icon { .gpx-popup__header-icon {
flex-shrink: 0; flex-shrink: 0;
width: 18px; width: 18px;
@@ -1488,6 +1641,22 @@ html, body {
font-size: 12px; font-size: 12px;
} }
.hiking-trail-countries {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
list-style: none;
margin: 0;
padding: 0;
}
.hiking-trail-countries__item label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.livetrack-panel__list { .livetrack-panel__list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+442
View File
@@ -0,0 +1,442 @@
import { WALKING_ICON } from "/js/icons.js";
import { firstSymbolLayerId } from "/js/radar.js";
import { escapeHtml } from "/js/gpx.js";
import { lineLength, formatDistance } from "/js/measure.js";
import { buildLineGraph, orderLanesAsync, LaneLayer } from "/vendor/maplibre-gl-lanes/maplibre-gl-lanes.mjs";
// Overpass has permissive CORS (verified: Access-Control-Allow-Origin: *),
// so this queries it directly from the browser — no server-side proxy
// needed, same "public API, no key" story as the Nominatim search already
// hits from here. One query per click (not per pan/move like the
// Wikipedia layer's live refetch), so this stays well within Overpass's
// shared rate limits.
//
// Three independent public mirrors (also CORS-open, verified), raced with
// Promise.any rather than called one at a time — the shared free instances
// vary a lot in load from one moment to the next, and a multi-second wait
// on a single one was the main complaint. Whichever answers first wins;
// the others are simply left to finish and get GC'd, not worth the extra
// AbortController plumbing for a single one-off per-click request.
const OVERPASS_APIS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
];
const CLICK_RADIUS_M = 40;
// OSM colour tag values are free text (untrusted) — only used as a CSS
// background if they look like an actual color (hex or a plain word),
// never interpolated unchecked into a style attribute.
const SAFE_COLOR = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$/;
// Pre-rendered raster tiles, not vector — waymarkedtrails.org already
// colors each route by its OSM `colour` tag (the KST/KČT-style paint-blaze
// colors used in Slovakia/Czechia), falling back to plain red where a route
// has none, so there's no styling to do here ourselves. Used whenever no
// offline extract is available at all — same "quietly renders nothing
// without a network" story as the Wikipedia/Radar/Contour lines overlays.
const ONLINE_SOURCE_ID = "hiking-trails-online";
const ONLINE_LAYER_ID = "hiking-trails-online-line";
const TILE_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png";
const ATTRIBUTION =
'Trails &copy; <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data &copy; OpenStreetMap contributors';
const OPACITY = 0.85;
// Offline: real vector trails for each country the viewer actually picks,
// GeoJSON generated at build time by Maptool::HikingTrails straight
// from the same Geofabrik .osm.pbf offline.pmtiles itself is built from
// (see lib/maptool/hiking_trails.rb, PmtilesBuilder.extract_hiking_trails)
// — no network needed, and no waiting on Overpass for click details either
// (hit-tested locally via LaneLayer.queryLane instead, see _handleClick
// below). One file per country rather than one combined file: a viewer
// covering a dozen countries' worth of offline map data rarely wants all
// of their hiking trails loaded at once, so this is opt-in per country
// instead.
//
// Drawn with maplibre-gl-lanes rather than a plain line layer: a stretch
// of path several marked routes share (very common with KST blazes) is
// otherwise one line drawn over and over, only the topmost color visible.
// Lanes puts each route in its own parallel lane instead. It needs routes
// sharing a path to share exact vertices, which OSM relations over shared
// ways already do. One LaneLayer over every picked country, not one per
// country, so lanes at a border stay ordered together. Doesn't drape over
// 3D terrain (sinks under raised ground, warns on the console) — a known
// limitation of the library, not worth working around for an overlay.
const LANES_LAYER_ID = "hiking-trails-lanes";
const LANE_SIZES = () => ({ spacing: 3.5, width: 3, casingWidth: 0.5 });
function offlineUrl(code) {
return `/tiles/hiking_trails/${code}.geojson`;
}
// waymarkedtrails.org's own fallback for an untagged route.
const DEFAULT_TRAIL_COLOR = "#c1272d";
// Real-world `colour` values seen in production data (see the 2026-09-13
// extraction against SK.osm.pbf) — anything outside this set (typos like
// "yelllow", multi-value combos like "blue, yellow", stray hex codes) falls
// back to DEFAULT_TRAIL_COLOR rather than being trusted as a CSS value
// straight from OSM tag text.
const KNOWN_TRAIL_COLORS = new Set(["red", "yellow", "blue", "green", "orange", "purple", "brown", "black", "white", "grey", "gray", "darkblue"]);
function normalizedTrailColor(raw) {
if (!raw) return DEFAULT_TRAIL_COLOR;
const trimmed = raw.trim();
if (KNOWN_TRAIL_COLORS.has(trimmed.toLowerCase())) return trimmed.toLowerCase();
return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : DEFAULT_TRAIL_COLOR;
}
// Online mode's highlight outline for whichever route(s) the last click
// matched — drawn as its own separate source/layer (real geometry, from the
// Overpass response), since the raster tiles have no paintable features to
// highlight at all. Offline lanes use LaneLayer.setHighlight instead. Same "yellow outline on a picked
// route" treatment the earlier SVG prototype visualization used.
const HIGHLIGHT_SOURCE_ID = "hiking-trails-highlight";
const HIGHLIGHT_LAYER_ID = "hiking-trails-highlight-line";
// An Overlays panel item — a plain on/off checkbox (raster overlay) until
// offline extracts exist for at least one country, at which point it
// becomes an expandable per-country checklist instead (same expand shape
// as RadarControl), since a viewer with a dozen countries' worth of
// offline map data rarely wants all of their hiking trails loaded, let
// alone parsed by the browser, at once.
export class HikingTrailsControl {
constructor(map) {
this._map = map;
this._active = false; // the online-mode checkbox's own state
this._offlineCountries = []; // codes with an extract available on the server
this._selectedCountries = new Set(); // codes currently shown, offline mode
this._trailData = new Map(); // code -> fetched GeoJSON, kept across toggles/style swaps
this._routeInfo = new Map(); // String(relation id) -> feature properties, for the panel
this._lanes = null; // the current LaneLayer, null when no country is shown
map.on("click", (event) => this._handleClick(event));
this._panel = document.getElementById("trail-panel");
this._panelBody = this._panel.querySelector(".trail-panel__body");
this._panel.querySelector(".trail-panel__close").addEventListener("click", () => this._hidePanel());
// setStyle() (switching Online/Offline) tears down all custom sources/
// layers — put whatever was showing back, same as every other
// Overlays control. The highlight is transient (tied to whichever
// trail is showing in the panel, if any) — simpler to just drop it
// here than try to carry it across a style swap.
map.on("style.load", () => {
if (this._active) this._addOnlineLayer();
this._clearHighlight();
if (this._selectedCountries.size > 0) this._rebuildLanes();
});
}
mount(li) {
li.innerHTML = `
<div class="overlays-panel__row">
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${WALKING_ICON}</span>
<button type="button" class="overlays-panel__name">Hiking trails</button>
</div>
<div class="overlays-panel__details" hidden>
<ul class="hiking-trail-countries"></ul>
</div>
`;
this._checkbox = li.querySelector(".overlays-panel__toggle");
this._nameButton = li.querySelector(".overlays-panel__name");
this._details = li.querySelector(".overlays-panel__details");
this._countryList = li.querySelector(".hiking-trail-countries");
this._checkbox.addEventListener("change", (event) => this._toggleOnline(event.target.checked));
this._nameButton.addEventListener("click", () => {
if (this._offlineCountries.length === 0) return; // nothing to expand into yet
const expanding = this._details.hidden;
this._details.hidden = !expanding;
this._nameButton.setAttribute("aria-expanded", String(expanding));
});
}
// Called once map.js's GET /api/hiking-trails check resolves. Switches
// this item from "online raster, plain on/off" to "offline vector,
// pick your countries" — the two are mutually exclusive rather than
// both offered at once, since once a real local extract exists there's
// no reason to still fall back to a third-party tile server for any of
// it.
setOfflineCountries(countries) {
this._offlineCountries = countries;
if (countries.length === 0) return;
if (this._active) this._toggleOnline(false);
this._checkbox.hidden = true;
this._countryList.innerHTML = countries
.map(
(code) => `
<li class="hiking-trail-countries__item">
<label>
<input type="checkbox" class="hiking-trail-countries__toggle" value="${code}">
${code}
</label>
</li>
`,
)
.join("");
this._countryList.querySelectorAll(".hiking-trail-countries__toggle").forEach((input) => {
input.addEventListener("change", (event) => this._toggleCountry(event.target.value, event.target.checked));
});
}
_toggleOnline(active) {
this._active = active;
if (active) this._addOnlineLayer();
else this._removeOnlineLayer();
}
async _toggleCountry(code, active) {
if (!active) {
this._selectedCountries.delete(code);
this._rebuildLanes();
return;
}
this._selectedCountries.add(code);
if (!this._trailData.has(code)) {
let geojson;
try {
geojson = await (await fetch(offlineUrl(code))).json();
} catch {
return; // degrade quietly, same story as every other optional layer here
}
// Normalized once here so an untrustworthy `colour` tag value never
// reaches the layer's color directly — same reasoning gpx.js's own
// feature.properties.color ||= TRACK_COLOR normalization gives.
for (const feature of geojson.features) {
feature.properties.color = normalizedTrailColor(feature.properties.colour);
}
this._trailData.set(code, geojson);
if (!this._selectedCountries.has(code)) return; // toggled back off while fetching
}
this._rebuildLanes();
}
// Rebuilt from scratch on every country toggle and style swap (setStyle
// drops custom layers too) — lane order depends on every route drawn, so
// there's no cheaper incremental path. Ordering runs in a worker; a newer
// rebuild started meanwhile wins, same token guard as _handleOnlineClick.
async _rebuildLanes() {
const build = (this._lanesBuild = {});
const features = [...this._selectedCountries].flatMap((code) => this._trailData.get(code)?.features || []);
let graph = null;
if (features.length > 0) {
graph = buildLineGraph(features, { routeProperty: "id" });
await orderLanesAsync(graph);
}
if (this._lanesBuild !== build) return;
this._hidePanel();
if (this._map.getLayer(LANES_LAYER_ID)) this._map.removeLayer(LANES_LAYER_ID);
this._lanes = null;
this._routeInfo = new Map(features.map((feature) => [String(feature.properties.id), feature.properties]));
if (!graph) return;
this._lanes = new LaneLayer({ id: LANES_LAYER_ID, graph, sizes: LANE_SIZES, casingColor: "rgba(255, 255, 255, 0.8)" });
// Below labels (same firstSymbolLayerId spot contour.js/radar.js use),
// so place names/POI icons stay readable on top of the trail lines.
this._map.addLayer(this._lanes, firstSymbolLayerId(this._map));
}
_handleClick(event) {
if (this._selectedCountries.size > 0) this._handleOfflineClick(event);
else if (this._active) this._handleOnlineClick(event);
}
// The lanes have real geometry to hit-test directly — no network
// round-trip, so this resolves instantly. Each route has its own lane, so
// a click picks exactly one, highlighted by the layer itself. A genuine
// miss just closes an already-open panel rather than showing a "not
// found" message, same as clicking empty space anywhere else on the map.
_handleOfflineClick(event) {
const hit = this._lanes?.queryLane(event.point, 8);
const info = hit && this._routeInfo.get(String(hit.route));
if (!info) {
this._hidePanel();
return;
}
this._lanes.setHighlight(hit.route);
this._showPanel(routeHtml(info));
}
_showPanel(html) {
this._panelBody.innerHTML = html;
this._panel.hidden = false;
}
_hidePanel() {
if (this._panel.hidden) return;
this._panel.hidden = true;
this._clearHighlight();
}
// The raster tiles have no queryable features (they're pixels), so
// "what trail is this" is answered with a one-off Overpass query instead
// of a MapLibre feature hit-test — an `around` search rather than an
// exact point match, since a click rarely lands exactly on the (already
// pixel-thick) rendered line.
async _handleOnlineClick(event) {
const { lng, lat } = event.lngLat;
this._showPanel(`<p class="gpx-popup__category">Looking for trails…</p>`);
// A newer click's request replaces this one while Overpass is still
// answering — same "in-flight response arrives after it's already
// stale" guard the old Popup-identity check gave, just keyed off a
// fresh token each click instead of a fresh Popup instance.
const request = (this._onlineRequest = {});
let relations;
try {
relations = await fetchNearbyRoutes(lng, lat);
} catch {
if (this._onlineRequest === request) this._panelBody.innerHTML = `<p class="gpx-popup__category">Could not reach Overpass.</p>`;
return;
}
if (this._onlineRequest !== request) return;
if (relations.length === 0) {
this._panelBody.innerHTML = `<p class="gpx-popup__category">No hiking trail found here.</p>`;
return;
}
this._panelBody.innerHTML = relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>");
this._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) })));
}
_addOnlineLayer() {
if (this._map.getSource(ONLINE_SOURCE_ID)) return;
this._map.addSource(ONLINE_SOURCE_ID, {
type: "raster",
tiles: [TILE_URL],
tileSize: 256,
maxzoom: 17,
attribution: ATTRIBUTION,
});
// Below labels (same firstSymbolLayerId spot contour.js/radar.js use),
// so place names/POI icons stay readable on top of the trail lines.
this._map.addLayer(
{ id: ONLINE_LAYER_ID, type: "raster", source: ONLINE_SOURCE_ID, paint: { "raster-opacity": OPACITY } },
firstSymbolLayerId(this._map),
);
}
_removeOnlineLayer() {
if (this._map.getLayer(ONLINE_LAYER_ID)) this._map.removeLayer(ONLINE_LAYER_ID);
if (this._map.getSource(ONLINE_SOURCE_ID)) this._map.removeSource(ONLINE_SOURCE_ID);
}
// features: an array of { geometry, color } — geometry a GeoJSON
// LineString or MultiLineString (mixed is fine), color the trail's own
// (already-normalized) colour — everything the last click matched, drawn
// as one highlight layer (a wider outline in the trail's own color,
// rather than a fixed color that would repaint it) on top of the base
// trail rendering.
_setHighlight(features) {
const data = {
type: "FeatureCollection",
features: features.map(({ geometry, color }) => ({ type: "Feature", properties: { color }, geometry })),
};
const source = this._map.getSource(HIGHLIGHT_SOURCE_ID);
if (source) {
source.setData(data);
return;
}
this._map.addSource(HIGHLIGHT_SOURCE_ID, { type: "geojson", data });
this._map.addLayer({
id: HIGHLIGHT_LAYER_ID,
type: "line",
source: HIGHLIGHT_SOURCE_ID,
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": ["get", "color"], "line-width": 5, "line-opacity": 0.9 },
});
}
_clearHighlight() {
this._lanes?.setHighlight(null);
if (this._map.getLayer(HIGHLIGHT_LAYER_ID)) this._map.removeLayer(HIGHLIGHT_LAYER_ID);
if (this._map.getSource(HIGHLIGHT_SOURCE_ID)) this._map.removeSource(HIGHLIGHT_SOURCE_ID);
}
}
// way(around:...) finds candidate ways near the click, then rel(bw...)
// walks backward from those to their parent relations — the same
// find-the-way-then-its-relation shape as clicking a route on
// waymarkedtrails.org's own site. `out geom` inlines each member way's
// full coordinate list right on the relation, so no separate stitching
// step is needed to get real geometry back.
async function queryOverpass(url, query) {
const response = await fetch(url, { method: "POST", body: `data=${encodeURIComponent(query)}` });
if (!response.ok) throw new Error(`overpass ${response.status}`);
return response.json();
}
async function fetchNearbyRoutes(lng, lat) {
const query = `[out:json][timeout:15];way(around:${CLICK_RADIUS_M},${lat},${lng})->.w;rel(bw.w)["route"="hiking"];out geom;`;
const data = await Promise.any(OVERPASS_APIS.map((url) => queryOverpass(url, query)));
return (data.elements || []).filter((element) => element.type === "relation");
}
// OSM doesn't tag a route's length directly — summed from each member
// way's own geometry instead, same haversine-sum measure.js's own Measure
// tool uses for a hand-drawn line. Shared endpoints between adjacent ways
// get walked twice, same as how a real hike over those ways would. Mirrors
// Maptool::HikingTrails.relation_feature's own distance calc server-side
// (lib/maptool/hiking_trails.rb) — this is the online, no-server-file path.
function routeDistance(relation) {
return (relation.members || [])
.filter((member) => member.type === "way" && member.geometry)
.reduce((total, member) => total + lineLength(member.geometry.map((point) => [point.lon, point.lat])), 0);
}
// Same member-way walk as routeDistance above, collecting coordinates
// instead of summing them — the online click highlight's geometry source,
// since there's no vector feature to read it back from the way the offline
// layer's own queryRenderedFeatures result already carries one.
function routeGeometry(relation) {
const lines = (relation.members || [])
.filter((member) => member.type === "way" && member.geometry)
.map((member) => member.geometry.map((point) => [point.lon, point.lat]));
return { type: "MultiLineString", coordinates: lines };
}
// Adapts a raw Overpass relation into the same shape offline GeoJSON
// features' properties already carry (lib/maptool/hiking_trails.rb's own
// ROUTE_TAGS + distance_meters + id) — one HTML builder (routeHtml) below
// serves both instead of two near-identical ones.
function overpassRouteInfo(relation) {
const tags = relation.tags || {};
return {
id: relation.id,
name: tags.name,
description: tags.description,
colour: tags.colour,
network: tags.network,
operator: tags.operator,
ref: tags.ref,
distance_meters: routeDistance(relation),
};
}
function routeHtml(info) {
const name = info.name || info.description || "Hiking trail";
const swatch = SAFE_COLOR.test(info.colour || "")
? `<span class="hiking-trail-popup__swatch" style="background:${info.colour}"></span>`
: "";
const meta = [formatDistance(info.distance_meters || 0)];
if (info.ref) meta.push(`#${info.ref}`);
if (info.network) meta.push(info.network);
if (info.operator) meta.push(escapeHtml(info.operator));
const parts = [
`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${swatch}</div>`,
`<p class="gpx-popup__category">${meta.filter(Boolean).join(" · ")}</p>`,
];
// description already used as the title above when there's no name — only
// shown again here as its own line if there both is a name *and* a
// description to add, so it's never rendered twice for the common
// no-name case.
if (info.name && info.description) parts.push(`<p class="gpx-popup__description">${escapeHtml(info.description)}</p>`);
parts.push(
`<p class="gpx-popup__link"><a href="https://www.openstreetmap.org/relation/${info.id}" target="_blank" rel="noopener noreferrer">View on OpenStreetMap →</a></p>`,
);
return parts.join("");
}
+5 -1
View File
@@ -328,7 +328,11 @@ const FISHING_ICON = `
const SCENIC_VIEWPOINT_ICON = ` const SCENIC_VIEWPOINT_ICON = `
<svg viewBox="0 0 22 22" fill="currentColor"><g transform="translate(4.000000, 0.000000)"><path d="M3.2,11c0,1.2-0.9,2.2-2.1,2.2c-1.2,0-2.1-1-2.1-2.2 c0-1.2,0.9-2.2,2.1-2.2C2.2,8.8,3.2,9.8,3.2,11"/><path d="M4.6,10.4v1.3l10.6,0.8v-3L4.6,10.4"/><path d="M2.3,7.5l1.1,0.7L7.7,2L5.3,0.5L2.3,7.5"/><path d="M4.4,9.8l7.9-3.8l-1.4-2.6L3.8,8.6L4.4,9.8z"/><path d="M2.3,14.5l1.1-0.7l4.3,6.2l-2.4,1.5L2.3,14.5"/><path d="M3.8,13.4l0.6-1.2l7.9,3.8l-1.4,2.6L3.8,13.4"/></g></svg>`; <svg viewBox="0 0 22 22" fill="currentColor"><g transform="translate(4.000000, 0.000000)"><path d="M3.2,11c0,1.2-0.9,2.2-2.1,2.2c-1.2,0-2.1-1-2.1-2.2 c0-1.2,0.9-2.2,2.1-2.2C2.2,8.8,3.2,9.8,3.2,11"/><path d="M4.6,10.4v1.3l10.6,0.8v-3L4.6,10.4"/><path d="M2.3,7.5l1.1,0.7L7.7,2L5.3,0.5L2.3,7.5"/><path d="M4.4,9.8l7.9-3.8l-1.4-2.6L3.8,8.6L4.4,9.8z"/><path d="M2.3,14.5l1.1-0.7l4.3,6.2l-2.4,1.5L2.3,14.5"/><path d="M3.8,13.4l0.6-1.2l7.9,3.8l-1.4,2.6L3.8,13.4"/></g></svg>`;
const WALKING_ICON = ` // Exported (unlike the other POI glyphs around it) — also used as the
// Overlays panel's Hiking trails glyph (hiking_trails.js), same "reuse an
// existing NPS-sourced pictogram" story as WILDERNESS_ICON above rather
// than hand-drawing another one.
export const WALKING_ICON = `
<svg viewBox="0 0 22 22" fill="currentColor"><path d="M10.4,3.6c1,0,1.8-0.8,1.7-1.8c0-1-0.8-1.8-1.8-1.7c-1,0-1.8,0.8-1.7,1.8C8.6,2.8,9.5,3.6,10.4,3.6z"/><path d="M16.5,9.5L13.9,8l-2.2-3.4c-0.3-0.4-0.9-0.8-1.5-0.7C9.7,4,9.3,4.2,9,4.4L5.8,7.7C5.7,7.8,5.6,8,5.6,8.1l-0.4,3.6 c0,0,0,0.1,0,0.1c0,0.4,0.4,0.8,0.8,0.8c0.4,0,0.7-0.4,0.8-0.8l0.3-3.1l1.1-1.1l-1,8.6l-1.9,4.1c-0.1,0.1-0.1,0.3-0.1,0.5 c0,0.7,0.6,1.2,1.2,1.2c0.5,0,0.9-0.3,1.1-0.7l2-4.4c0-0.1,0.2-0.4,0.2-0.5l0.2-2.1l2.1,6.9c0.2,0.5,0.6,0.8,1.2,0.8 c0.7,0,1.2-0.6,1.2-1.2c0-0.1,0-0.1,0-0.2L11.5,11l0.4-3.1l0.8,1.3c0.1,0.1,0.1,0.2,0.2,0.2l2.8,1.6c0.1,0,0.2,0.1,0.4,0.1 c0.4,0,0.8-0.4,0.8-0.8C16.9,9.8,16.7,9.6,16.5,9.5z"/></svg>`; <svg viewBox="0 0 22 22" fill="currentColor"><path d="M10.4,3.6c1,0,1.8-0.8,1.7-1.8c0-1-0.8-1.8-1.8-1.7c-1,0-1.8,0.8-1.7,1.8C8.6,2.8,9.5,3.6,10.4,3.6z"/><path d="M16.5,9.5L13.9,8l-2.2-3.4c-0.3-0.4-0.9-0.8-1.5-0.7C9.7,4,9.3,4.2,9,4.4L5.8,7.7C5.7,7.8,5.6,8,5.6,8.1l-0.4,3.6 c0,0,0,0.1,0,0.1c0,0.4,0.4,0.8,0.8,0.8c0.4,0,0.7-0.4,0.8-0.8l0.3-3.1l1.1-1.1l-1,8.6l-1.9,4.1c-0.1,0.1-0.1,0.3-0.1,0.5 c0,0.7,0.6,1.2,1.2,1.2c0.5,0,0.9-0.3,1.1-0.7l2-4.4c0-0.1,0.2-0.4,0.2-0.5l0.2-2.1l2.1,6.9c0.2,0.5,0.6,0.8,1.2,0.8 c0.7,0,1.2-0.6,1.2-1.2c0-0.1,0-0.1,0-0.2L11.5,11l0.4-3.1l0.8,1.3c0.1,0.1,0.1,0.2,0.2,0.2l2.8,1.6c0.1,0,0.2,0.1,0.4,0.1 c0.4,0,0.8-0.4,0.8-0.8C16.9,9.8,16.7,9.6,16.5,9.5z"/></svg>`;
const GUIDED_TOURS_ICON = ` const GUIDED_TOURS_ICON = `
+17 -1
View File
@@ -5,6 +5,11 @@ import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
const SESSIONS_URL = "/api/livetrack/sessions"; const SESSIONS_URL = "/api/livetrack/sessions";
const TRACK_SOURCE = "livetrack-track"; const TRACK_SOURCE = "livetrack-track";
const TRACK_COLOR = "#7209b7"; const TRACK_COLOR = "#7209b7";
// The planned route (only present when the activity was started from a
// saved Garmin course, see LiveTrackClient#fetch) — dashed and muted so it
// reads as a reference line, not more recorded data next to the actual
// track's own color.
const PLANNED_COLOR = "#888";
// Garmin devices post a new point roughly every 15s (postTrackPointFrequency // Garmin devices post a new point roughly every 15s (postTrackPointFrequency
// in the session data) — polling twice that often would just re-fetch the // in the session data) — polling twice that often would just re-fetch the
// same 400+KB page for nothing most of the time. // same 400+KB page for nothing most of the time.
@@ -171,11 +176,22 @@ export class LiveTrackPanelControl {
if (this._map.getSource(TRACK_SOURCE)) return; if (this._map.getSource(TRACK_SOURCE)) return;
this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson }); this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson });
// Added before the actual track's own line layer so it paints
// underneath — the recorded track is the thing that actually matters
// when the two overlap.
this._map.addLayer({
id: `${TRACK_SOURCE}-planned`,
type: "line",
source: TRACK_SOURCE,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "planned"]],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": PLANNED_COLOR, "line-width": 2, "line-dasharray": [2, 2] },
});
this._map.addLayer({ this._map.addLayer({
id: `${TRACK_SOURCE}-line`, id: `${TRACK_SOURCE}-line`,
type: "line", type: "line",
source: TRACK_SOURCE, source: TRACK_SOURCE,
filter: ["==", ["geometry-type"], "LineString"], filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "actual"]],
layout: { "line-cap": "round", "line-join": "round" }, layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 }, paint: { "line-color": TRACK_COLOR, "line-width": 3 },
}); });
+67
View File
@@ -355,6 +355,65 @@ class ZoomLevelControl {
} }
} }
// Tilts the camera between a flat top-down view and a fixed 3D-perspective
// pitch — bearing stays untouched (this map never rotates, see the
// NavigationControl comment below), so this is purely "look at it from an
// angle", not a rotate/compass control. Independent of the "Terrain
// shading" overlay toggle (overlays.js) — that adds a raster-dem hillshade/
// color-relief layer, this just moves the camera; combining both gives a
// real sense of relief but neither depends on the other.
const PITCH_3D = 60;
class PitchToggleControl {
onAdd(map) {
this._map = map;
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group pitch-toggle-ctrl";
this._button = document.createElement("button");
this._button.type = "button";
this._button.className = "pitch-toggle-ctrl__button";
this._button.addEventListener("click", () => this._toggle());
this._container.appendChild(this._button);
this._update = () => this._render();
map.on("pitchend", this._update);
this._render();
return this._container;
}
onRemove() {
this._map.off("pitchend", this._update);
this._container.remove();
}
getDefaultPosition() {
return "top-left";
}
// Set once overlaysControl exists (it's built later in this file) —
// lets raised terrain (OverlaysControl.syncTerrain, see overlays.js)
// follow the pitch toggle without this control needing to know anything
// about hillshade/DEM sources itself.
setOverlaysControl(overlaysControl) {
this._overlaysControl = overlaysControl;
this._overlaysControl.syncTerrain(this._map.getPitch() > 0);
}
_toggle() {
this._map.easeTo({ pitch: this._map.getPitch() > 0 ? 0 : PITCH_3D, duration: 300 });
}
_render() {
const is3d = this._map.getPitch() > 0;
this._button.textContent = is3d ? "2D" : "3D";
this._button.title = is3d ? "Switch to 2D view" : "Switch to 3D view";
this._button.setAttribute("aria-label", this._button.title);
this._button.classList.toggle("active", is3d);
this._overlaysControl?.syncTerrain(is3d);
}
}
const map = new MaplibreMap({ const map = new MaplibreMap({
container: "map", container: "map",
style: ONLINE_STYLE, style: ONLINE_STYLE,
@@ -391,6 +450,8 @@ map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "top-left");
// (NavigationControl's default third button) would just be dead weight. // (NavigationControl's default third button) would just be dead weight.
map.addControl(new NavigationControl({ showCompass: false }), "top-left"); map.addControl(new NavigationControl({ showCompass: false }), "top-left");
map.addControl(new ZoomLevelControl(), "top-left"); map.addControl(new ZoomLevelControl(), "top-left");
const pitchToggle = new PitchToggleControl();
map.addControl(pitchToggle, "top-left");
map.addControl(new MeasureControl(), "top-left"); map.addControl(new MeasureControl(), "top-left");
// Writing a note needs a login (POST /api/gpx/notes) — no point adding a // Writing a note needs a login (POST /api/gpx/notes) — no point adding a
// button that can only ever end in a 401 for a logged-out viewer, same // button that can only ever end in a 401 for a logged-out viewer, same
@@ -487,6 +548,7 @@ sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title=
new GpxPanelControl(map, profilePanel).mount(sidebar); new GpxPanelControl(map, profilePanel).mount(sidebar);
const overlaysControl = new OverlaysControl(map); const overlaysControl = new OverlaysControl(map);
overlaysControl.mount(sidebar); overlaysControl.mount(sidebar);
pitchToggle.setOverlaysControl(overlaysControl);
// Running a plugin needs a login (POST /api/plugins/:name/run) — same // Running a plugin needs a login (POST /api/plugins/:name/run) — same
// "hide the write-only affordance" story as NotesControl above. // "hide the write-only affordance" story as NotesControl above.
if (isLoggedIn()) new PluginsControl(map).mount(sidebar); if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
@@ -545,6 +607,11 @@ fetch("/api/terrain-tiles")
}) })
.catch(() => {}); .catch(() => {});
fetch("/api/hiking-trails")
.then((response) => response.json())
.then(({ countries }) => overlaysControl.setHikingTrailsOfflineCountries(countries))
.catch(() => {});
fetch("/api/kiwix") fetch("/api/kiwix")
.then((response) => response.json()) .then((response) => response.json())
.then(({ available }) => overlaysControl.setWikiAvailable(available)) .then(({ available }) => overlaysControl.setWikiAvailable(available))
+5 -2
View File
@@ -317,7 +317,9 @@ function haversine([lng1, lat1], [lng2, lat2]) {
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a)); return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a));
} }
function lineLength(points) { // Exported — also used by hiking_trails.js to total up an OSM route
// relation's member ways into one distance, same haversine-sum approach.
export function lineLength(points) {
let total = 0; let total = 0;
for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]); for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]);
return total; return total;
@@ -347,7 +349,8 @@ function polygonArea(points) {
return Math.abs((area * EARTH_RADIUS_M * EARTH_RADIUS_M) / 2); return Math.abs((area * EARTH_RADIUS_M * EARTH_RADIUS_M) / 2);
} }
function formatDistance(meters) { // Exported — also used by hiking_trails.js's route-details popup.
export function formatDistance(meters) {
if (meters === 0) return ""; if (meters === 0) return "";
return meters >= 1000 ? `${(meters / 1000).toFixed(2)} km` : `${Math.round(meters)} m`; return meters >= 1000 ? `${(meters / 1000).toFixed(2)} km` : `${Math.round(meters)} m`;
} }
+124 -18
View File
@@ -17,12 +17,14 @@ function formatElapsed(ms) {
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
} }
// "Building…" alone gave no way to tell a slow build from a stuck one — // "Building…" alone gave no way to tell a slow build from a stuck one, or
// elapsed time answers "is it alive" regardless of how long a given // which of the current selection's sources it's actually working on right
// selection takes. // now — elapsed time answers "is it alive", status.step (see
// PmtilesBuilder.run_job) answers "doing what, and what's left".
function formatRunning(status) { function formatRunning(status) {
const elapsed = status.started_at ? ` (${formatElapsed(Date.now() - new Date(status.started_at).getTime())})` : ""; const elapsed = status.started_at ? ` (${formatElapsed(Date.now() - new Date(status.started_at).getTime())})` : "";
return `Building…${elapsed}`; const step = status.step ? `: ${status.step}` : "";
return `Building…${elapsed}${step}`;
} }
// Vendored square-cropped SVGs (public/vendor/flag-icons/, MIT, see // Vendored square-cropped SVGs (public/vendor/flag-icons/, MIT, see
@@ -79,26 +81,72 @@ class RegionPicker {
container.appendChild(section); container.appendChild(section);
let data; let data;
let cache;
try { try {
data = await (await fetch("/api/offline-regions")).json(); [data, cache] = await Promise.all([
fetch("/api/offline-regions").then((r) => r.json()),
fetch("/api/offline-tiles/cache").then((r) => r.json()),
]);
} catch { } catch {
this._list.innerHTML = `<li class="offline-data-panel__region-status">Could not load the region list.</li>`; this._list.innerHTML = `<li class="offline-data-panel__region-status">Could not load the region list.</li>`;
return; return;
} }
const selected = new Set(data.selected); const selected = new Set(data.selected);
const cached = new Map(cache.extracts.map((e) => [e.code, e]));
this._list.innerHTML = data.available this._list.innerHTML = data.available
.map( .map((region) => {
(region) => ` const extract = cached.get(region.code);
// Downloaded-but-not-(yet)-built is a real state (ticked, not
// Applied yet) as is built-but-no-longer-selected (unticked since)
// — this badge reflects the cache on disk, independent of either.
const cacheInfo = extract
? `
<span class="offline-data-panel__cache-info">
${formatBytes(extract.size)}
${
isLoggedIn()
? `<button type="button" class="offline-data-panel__cache-delete" data-code="${escapeHtml(region.code)}" data-name="${escapeHtml(region.name)}" title="Delete downloaded data for ${escapeHtml(region.name)}">✕</button>`
: ""
}
</span>`
: "";
return `
<li> <li>
<label> <label>
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}> <input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
<img class="offline-data-panel__flag" src="${flagIconUrl(region.code)}" alt="" width="18" height="18"> <img class="offline-data-panel__flag" src="${flagIconUrl(region.code)}" alt="" width="18" height="18">
${escapeHtml(region.name)} ${escapeHtml(region.name)}
</label> </label>
</li>`, ${cacheInfo}
) </li>`;
})
.join(""); .join("");
this._list.querySelectorAll(".offline-data-panel__cache-delete").forEach((button) => {
button.addEventListener("click", () => this._deleteCache(button));
});
}
// Removes just this row's cache badge on success, rather than re-fetching
// and re-rendering the whole list — that would also overwrite whatever
// unticked/ticked-but-not-yet-Applied state the user has pending.
async _deleteCache(button) {
const code = button.dataset.code;
if (!window.confirm(`Delete downloaded data for ${button.dataset.name}? This can't be undone.`)) return;
button.disabled = true;
try {
const response = await fetch(`/api/offline-tiles/cache/${code}`, { method: "DELETE" });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `could not delete: ${response.status}`);
} catch (err) {
window.alert(err.message);
button.disabled = false;
return;
}
button.closest(".offline-data-panel__cache-info").remove();
} }
async _apply() { async _apply() {
@@ -131,9 +179,10 @@ class RegionPicker {
// one class covers both rather than writing the same fetch/poll logic // one class covers both rather than writing the same fetch/poll logic
// twice. // twice.
class DataFileRow { class DataFileRow {
constructor({ label, statusUrl, buildUrl, buildStatusUrl, checkUpdateUrl, formatMeta }) { constructor({ label, statusUrl, deleteUrl, buildUrl, buildStatusUrl, checkUpdateUrl, formatMeta }) {
this._label = label; this._label = label;
this._statusUrl = statusUrl; this._statusUrl = statusUrl;
this._deleteUrl = deleteUrl;
this._buildUrl = buildUrl; this._buildUrl = buildUrl;
this._buildStatusUrl = buildStatusUrl; this._buildStatusUrl = buildStatusUrl;
this._checkUpdateUrl = checkUpdateUrl; this._checkUpdateUrl = checkUpdateUrl;
@@ -144,33 +193,71 @@ class DataFileRow {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "offline-data-panel__row"; row.className = "offline-data-panel__row";
row.innerHTML = ` row.innerHTML = `
<div class="offline-data-panel__row-head">
<strong>${this._label}</strong> <strong>${this._label}</strong>
<button type="button" class="offline-data-panel__delete" hidden>Delete</button>
</div>
<p class="offline-data-panel__status">Checking…</p> <p class="offline-data-panel__status">Checking…</p>
`; `;
this._statusEl = row.querySelector(".offline-data-panel__status"); this._statusEl = row.querySelector(".offline-data-panel__status");
this._deleteButton = row.querySelector(".offline-data-panel__delete");
this._deleteButton.addEventListener("click", () => this._delete());
container.appendChild(row); container.appendChild(row);
this._refreshStatus(); this._refreshStatus();
} }
// Single place that writes to _statusEl, so the error styling (pre-wrap,
// scrollable, red — see style.css) never stays stuck on once a build's
// failure message has scrolled past into a normal status line.
_setStatus(text, isError = false) {
this._statusEl.textContent = text;
this._statusEl.classList.toggle("offline-data-panel__status--error", isError);
}
async _refreshStatus() { async _refreshStatus() {
let data; let data;
try { try {
data = await (await fetch(this._statusUrl)).json(); data = await (await fetch(this._statusUrl)).json();
} catch { } catch {
this._statusEl.textContent = "Could not check status."; this._setStatus("Could not check status.");
return; return;
} }
if (!data.available) { if (!data.available) {
this._statusEl.textContent = "Not generated yet."; this._setStatus("Not generated yet.");
this._deleteButton.hidden = true;
return; return;
} }
const updated = new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" }); const updated = new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
this._statusEl.textContent = `${formatBytes(data.size)} · updated ${updated}${this._formatMeta(data.meta)}`; this._setStatus(`${formatBytes(data.size)} · updated ${updated}${this._formatMeta(data.meta)}`);
this._deleteButton.hidden = !isLoggedIn();
this._checkUpdate(); this._checkUpdate();
} }
// Deletes the built file (and, for offline.pmtiles, its whole per-country
// download cache — see PmtilesBuilder.delete_offline_data) so the row
// goes back to "Not generated yet." — a manual way to reclaim space or
// force a completely fresh rebuild, since Apply alone never removes
// anything already on disk.
async _delete() {
if (!window.confirm(`Delete ${this._label.toLowerCase()}? This can't be undone.`)) return;
this._deleteButton.disabled = true;
try {
const response = await fetch(this._deleteUrl, { method: "DELETE" });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `could not delete: ${response.status}`);
} catch (err) {
window.alert(err.message);
return;
} finally {
this._deleteButton.disabled = false;
}
this._refreshStatus();
}
// Separate from _refreshStatus's own fetch (and only called once, right // Separate from _refreshStatus's own fetch (and only called once, right
// after — not polled): this one reaches an external service (Geofabrik // after — not polled): this one reaches an external service (Geofabrik
// or Mapterhorn's manifest, see lib/maptool/pmtiles_builder.rb), so it's // or Mapterhorn's manifest, see lib/maptool/pmtiles_builder.rb), so it's
@@ -195,17 +282,34 @@ class DataFileRow {
// Called by OfflineDataControl once RegionPicker's Apply has saved a // Called by OfflineDataControl once RegionPicker's Apply has saved a
// new selection — not tied to a button of this row's own. // new selection — not tied to a button of this row's own.
async triggerBuild() { async triggerBuild() {
this._statusEl.textContent = "Starting…"; this._setStatus("Checking…");
try {
// false (not null/true — see offline_update_available? in
// pmtiles_builder.rb) means the just-saved selection matches what's
// already on disk byte-for-byte: same countries, nothing upstream
// changed. Rebuilding would just burn bandwidth/CPU to reproduce
// the same file, so skip it. null (never built, or can't tell)
// still falls through to building below.
const check = await (await fetch(this._checkUpdateUrl)).json();
if (check.update_available === false) {
this._refreshStatus();
return;
}
} catch {
// couldn't tell — fall through and build rather than silently skip
}
this._setStatus("Starting…");
try { try {
const response = await fetch(this._buildUrl, { method: "POST" }); const response = await fetch(this._buildUrl, { method: "POST" });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `build failed: ${response.status}`); if (!response.ok) throw new Error(data.error || `build failed: ${response.status}`);
} catch (err) { } catch (err) {
this._statusEl.textContent = err.message; this._setStatus(err.message, true);
return; return;
} }
this._statusEl.textContent = "Building…"; this._setStatus("Building…");
this._poll(); this._poll();
} }
@@ -222,13 +326,13 @@ class DataFileRow {
} }
if (status.state === "running") { if (status.state === "running") {
this._statusEl.textContent = formatRunning(status); this._setStatus(formatRunning(status));
this._poll(); this._poll();
return; return;
} }
if (status.state === "error") { if (status.state === "error") {
this._statusEl.textContent = `Build failed: ${status.message}`; this._setStatus(`Build failed: ${status.message}`, true);
} else { } else {
this._refreshStatus(); this._refreshStatus();
} }
@@ -249,6 +353,7 @@ export class OfflineDataControl {
new DataFileRow({ new DataFileRow({
label: "Offline map data", label: "Offline map data",
statusUrl: "/api/offline-tiles", statusUrl: "/api/offline-tiles",
deleteUrl: "/api/offline-tiles",
buildUrl: "/api/offline-tiles/build", buildUrl: "/api/offline-tiles/build",
buildStatusUrl: "/api/offline-tiles/build-status", buildStatusUrl: "/api/offline-tiles/build-status",
checkUpdateUrl: "/api/offline-tiles/check-update", checkUpdateUrl: "/api/offline-tiles/check-update",
@@ -257,6 +362,7 @@ export class OfflineDataControl {
new DataFileRow({ new DataFileRow({
label: "Offline terrain data", label: "Offline terrain data",
statusUrl: "/api/terrain-tiles", statusUrl: "/api/terrain-tiles",
deleteUrl: "/api/terrain-tiles",
buildUrl: "/api/terrain-tiles/build", buildUrl: "/api/terrain-tiles/build",
buildStatusUrl: "/api/terrain-tiles/build-status", buildStatusUrl: "/api/terrain-tiles/build-status",
checkUpdateUrl: "/api/terrain-tiles/check-update", checkUpdateUrl: "/api/terrain-tiles/check-update",
+63 -1
View File
@@ -5,6 +5,7 @@ import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.js";
import { RadarControl } from "/js/radar.js"; import { RadarControl } from "/js/radar.js";
import { ShadowControl } from "/js/shadow.js"; import { ShadowControl } from "/js/shadow.js";
import { ContourControl } from "/js/contour.js"; import { ContourControl } from "/js/contour.js";
import { HikingTrailsControl } from "/js/hiking_trails.js";
const ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below const ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below
const ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker badge below const ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker badge below
@@ -16,6 +17,16 @@ const WIKI_BADGE_COLOR = "#795548";
const HILLSHADE_ID = "hillshade-dem"; const HILLSHADE_ID = "hillshade-dem";
const COLOR_RELIEF_ID = "elevation-tint"; const COLOR_RELIEF_ID = "elevation-tint";
// MapLibre's own default light ({anchor:"viewport", intensity:0.5,
// position:[1.15,210,30]}) sits the sun almost overhead (polar 30°) — fine
// for flat 2D, but it barely rakes across 3D terrain at all, which is
// most of why real relief still looked flat/washed out even once the mesh
// itself was raised. Lower polar angle = lower sun = longer, more visible
// slope shadows; azimuth 315 is the classic NW hillshade convention.
// anchor:"map" (not "viewport") keeps the sun fixed to geography rather
// than spinning with the camera as you look around a tilted view.
const TERRAIN_LIGHT = { anchor: "map", color: "#fff", intensity: 0.6, position: [1.5, 315, 65] };
const DEFAULT_LIGHT = { anchor: "viewport", color: "#fff", intensity: 0.5, position: [1.15, 210, 30] };
// Hypsometric tint reusing this app's own palette (wood_a's green at // Hypsometric tint reusing this app's own palette (wood_a's green at
// valley floor, the road casing brown through the mid-slopes) rather than // valley floor, the road casing brown through the mid-slopes) rather than
// a generic rainbow ramp, so it reads as part of the same map rather than // a generic rainbow ramp, so it reads as part of the same map rather than
@@ -52,6 +63,7 @@ class HillshadeControl {
// sources/layers — put hillshade back if it was on. // sources/layers — put hillshade back if it was on.
map.on("style.load", () => { map.on("style.load", () => {
if (this._active) this._addLayer(); if (this._active) this._addLayer();
this.syncTerrain(this._map.getPitch() > 0);
}); });
} }
@@ -73,8 +85,44 @@ class HillshadeControl {
_toggle(active) { _toggle(active) {
this._active = active; this._active = active;
// Clear first: setTerrain may still be pointing at HILLSHADE_ID from
// before, and removing that source out from under it is worth avoiding
// even for the instant before syncTerrain below re-evaluates it.
if (this._map.getTerrain()) this._map.setTerrain(null);
if (active) this._addLayer(); if (active) this._addLayer();
else this._removeLayer(); else this._removeLayer();
this.syncTerrain(this._map.getPitch() > 0);
}
// Real terrain displacement (map.setTerrain), not just the flat
// hillshade/color-relief paint above — called both when this checkbox
// changes and when the 3D pitch toggle changes (map.js's
// PitchToggleControl, via OverlaysControl.syncTerrain below), since
// showing raised terrain needs both "tilted enough to see it" and "the
// DEM source this checkbox controls is actually loaded".
syncTerrain(pitched) {
// setLight/setTerrain throw if the style isn't loaded yet — called
// synchronously from map.js's setOverlaysControl during page init,
// before the first style.load; that listener re-invokes this once it
// actually is loaded, so bailing here just skips the premature call.
if (!this._map.isStyleLoaded()) return;
const terrainOn = pitched && this._active && this._map.getSource(HILLSHADE_ID);
if (terrainOn) {
this._map.setTerrain({ source: HILLSHADE_ID, exaggeration: 3 });
} else if (this._map.getTerrain()) {
this._map.setTerrain(null);
}
this._map.setLight(terrainOn ? TERRAIN_LIGHT : DEFAULT_LIGHT);
// The raster hillshade layer fakes relief by shading a flat texture —
// once real 3D geometry is doing that job (with its own lit shading
// for free), the raster version just looks like a blurry, low-res
// texture pasted on top of it. It's the only way to suggest relief in
// flat 2D though, so only hide it once real terrain is actually up.
// The color-relief elevation tint stays regardless — that's
// colour-by-height information, not a shading trick.
if (this._map.getLayer(HILLSHADE_ID)) {
this._map.setLayoutProperty(HILLSHADE_ID, "visibility", terrainOn ? "none" : "visible");
}
} }
_addLayer() { _addLayer() {
@@ -137,6 +185,7 @@ export class OverlaysControl {
this._radar = new RadarControl(map); this._radar = new RadarControl(map);
this._shadow = new ShadowControl(map); this._shadow = new ShadowControl(map);
this._contour = new ContourControl(map); this._contour = new ContourControl(map);
this._hikingTrails = new HikingTrailsControl(map);
this._wikiEnabled = false; // the checkbox's own state this._wikiEnabled = false; // the checkbox's own state
this._wikiMoveHandler = null; // the moveend listener driving its live refetch, while enabled this._wikiMoveHandler = null; // the moveend listener driving its live refetch, while enabled
this._wikiRequestSeq = 0; // ignores an in-flight refetch a newer one has already superseded this._wikiRequestSeq = 0; // ignores an in-flight refetch a newer one has already superseded
@@ -170,13 +219,15 @@ export class OverlaysControl {
<li class="overlays-panel__item overlays-panel__item--expandable"></li> <li class="overlays-panel__item overlays-panel__item--expandable"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li> <li class="overlays-panel__item overlays-panel__item--expandable"></li>
<li class="overlays-panel__item"></li> <li class="overlays-panel__item"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
`; `;
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem] = list.children; const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem, hikingTrailsItem] = list.children;
this._hillshade.mount(hillshadeItem); this._hillshade.mount(hillshadeItem);
this._mountWiki(wikiItem); this._mountWiki(wikiItem);
this._radar.mount(radarItem); this._radar.mount(radarItem);
this._shadow.mount(shadowItem); this._shadow.mount(shadowItem);
this._contour.mount(contourItem); this._contour.mount(contourItem);
this._hikingTrails.mount(hikingTrailsItem);
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
@@ -194,6 +245,17 @@ export class OverlaysControl {
this._hillshade.setOfflineAvailable(available); this._hillshade.setOfflineAvailable(available);
} }
setHikingTrailsOfflineCountries(countries) {
this._hikingTrails.setOfflineCountries(countries);
}
// Called by map.js's PitchToggleControl whenever the 3D pitch toggle
// changes, so raised terrain follows the camera tilt rather than needing
// the "Terrain shading" checkbox re-clicked to notice.
syncTerrain(pitched) {
this._hillshade.syncTerrain(pitched);
}
setShadowOfflineAvailable(available) { setShadowOfflineAvailable(available) {
this._shadow.setOfflineAvailable(available); this._shadow.setOfflineAvailable(available);
} }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Steve Vigneau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large. Load diff
+60
View File
@@ -39,6 +39,19 @@ class AppTest < Minitest::Test
assert_equal({ "available" => false }, json_body(response)) assert_equal({ "available" => false }, json_body(response))
end end
def test_hiking_trails_reports_no_countries_when_none_extracted_yet
response = request.get("/api/hiking-trails")
assert_equal({ "countries" => [] }, json_body(response))
end
def test_hiking_trails_file_404s_for_a_country_with_no_extract
assert_equal 404, request.get("/tiles/hiking_trails/SK.geojson").status
end
def test_hiking_trails_file_404s_for_a_malformed_country_code
assert_equal 404, request.get("/tiles/hiking_trails/../../etc/passwd.geojson").status
end
def test_kiwix_reports_unavailable_when_no_url_configured def test_kiwix_reports_unavailable_when_no_url_configured
response = request.get("/api/kiwix") response = request.get("/api/kiwix")
assert_equal({ "available" => false, "url" => "" }, json_body(response)) assert_equal({ "available" => false, "url" => "" }, json_body(response))
@@ -72,6 +85,9 @@ class AppTest < Minitest::Test
assert_equal 401, request.post("/api/plugins/test-plugin/run").status assert_equal 401, request.post("/api/plugins/test-plugin/run").status
assert_equal 401, request.post("/api/offline-tiles/build").status assert_equal 401, request.post("/api/offline-tiles/build").status
assert_equal 401, request.post("/api/terrain-tiles/build").status assert_equal 401, request.post("/api/terrain-tiles/build").status
assert_equal 401, request.delete("/api/offline-tiles").status
assert_equal 401, request.delete("/api/offline-tiles/cache/SK").status
assert_equal 401, request.delete("/api/terrain-tiles").status
end end
# --- GPX/FIT upload and the tree --- # --- GPX/FIT upload and the tree ---
@@ -417,6 +433,50 @@ class AppTest < Minitest::Test
thread&.join thread&.join
end end
def test_offline_tiles_delete_is_ok_when_nothing_was_built
assert_equal 200, request.delete("/api/offline-tiles", **logged_in_env).status
end
def test_offline_tiles_delete_removes_the_file_and_cache
path = File.join(Maptool::App::DATA_DIR, "offline.pmtiles")
cache_dir = File.join(Maptool::App::DATA_DIR, "geofabrik_cache")
File.write(path, "fake")
FileUtils.mkdir_p(cache_dir)
File.write(File.join(cache_dir, "SK.osm.pbf"), "fake")
response = request.delete("/api/offline-tiles", **logged_in_env)
assert_equal 200, response.status
refute_path_exists path
refute_path_exists cache_dir
end
def test_offline_tiles_cache_lists_and_deletes_one_country
cache_dir = File.join(Maptool::App::DATA_DIR, "geofabrik_cache")
FileUtils.mkdir_p(cache_dir)
File.write(File.join(cache_dir, "SK.osm.pbf"), "fake")
extracts = json_body(request.get("/api/offline-tiles/cache"))["extracts"]
assert_equal ["SK"], extracts.map { |e| e["code"] }
response = request.delete("/api/offline-tiles/cache/SK", **logged_in_env)
assert_equal 200, response.status
refute_path_exists File.join(cache_dir, "SK.osm.pbf")
ensure
FileUtils.remove_entry(cache_dir) if cache_dir && Dir.exist?(cache_dir)
end
def test_offline_tiles_delete_is_409_when_a_build_is_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, download_extract: ->(_url, _dest) { gate.pop })
assert_equal 409, request.delete("/api/offline-tiles", **logged_in_env).status
ensure
gate.push("unused")
thread&.join
end
# --- LiveTrack webhook --- # --- LiveTrack webhook ---
def test_livetrack_webhook_requires_the_configured_secret def test_livetrack_webhook_requires_the_configured_secret
+125
View File
@@ -0,0 +1,125 @@
require_relative "test_helper"
require "maptool/hiking_trails"
# No real osmium calls — extract() is a thin subprocess-plus-parse wrapper
# (same "not independently tested" reasoning OverpassPois.fetch gets), but
# parse_opl is pure text in, GeoJSON out, and that's the part actually worth
# protecting: the OPL escaping rules and the way-id -> relation-member
# stitching are easy to get subtly wrong and osmium isn't installed in every
# environment this test suite runs in.
class HikingTrailsTest < Minitest::Test
def test_parse_opl_builds_a_multilinestring_from_member_way_geometry
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
w2 Thighway=path Nn11x20.088y49.18,n12x20.089y49.181
r1 Troute=hiking,type=route,name=Rysy Mw1@,w2@
OPL
features = Maptool::HikingTrails.parse_opl(opl)["features"]
assert_equal 1, features.size
feature = features.first
assert_equal "MultiLineString", feature["geometry"]["type"]
assert_equal [[[20.087, 49.179], [20.088, 49.18]], [[20.088, 49.18], [20.089, 49.181]]], feature["geometry"]["coordinates"]
assert_equal "Rysy", feature["properties"]["name"]
end
def test_parse_opl_skips_relations_that_are_not_route_hiking
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
r1 Ttype=multipolygon Mw1@outer
OPL
assert_empty Maptool::HikingTrails.parse_opl(opl)["features"]
end
def test_parse_opl_falls_back_to_description_when_there_is_no_name
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
r1 Troute=hiking,type=route,description=Rázc.%20%pri%20%Žabom%20%potoku%20%-%20%Rysy Mw1@
OPL
feature = Maptool::HikingTrails.parse_opl(opl)["features"].first
assert_equal "Rázc. pri Žabom potoku - Rysy", feature["properties"]["name"]
end
def test_parse_opl_unescapes_commas_and_equals_in_tag_values
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
r1 Troute=hiking,type=route,name=Salt%2c%%20%Pepper%3d%Co Mw1@
OPL
feature = Maptool::HikingTrails.parse_opl(opl)["features"].first
assert_equal "Salt, Pepper=Co", feature["properties"]["name"]
end
def test_parse_opl_carries_over_colour_network_operator_ref
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
r1 Troute=hiking,type=route,name=Trail,colour=red,network=rwn,operator=KST,ref=0933 Mw1@
OPL
properties = Maptool::HikingTrails.parse_opl(opl)["features"].first["properties"]
assert_equal "red", properties["colour"]
assert_equal "rwn", properties["network"]
assert_equal "KST", properties["operator"]
assert_equal "0933", properties["ref"]
end
def test_parse_opl_computes_distance_from_member_way_geometry
# Roughly 111.2m per 0.001 degree of latitude at the equator.
opl = <<~OPL
w1 Thighway=path Nn10x0.0y0.0,n11x0.0y0.001
r1 Troute=hiking,type=route,name=Trail Mw1@
OPL
distance = Maptool::HikingTrails.parse_opl(opl)["features"].first["properties"]["distance_meters"]
assert_in_delta 111.2, distance, 1.0
end
def test_parse_opl_drops_a_relation_whose_member_ways_are_all_missing
opl = <<~OPL
r1 Troute=hiking,type=route,name=Ghost Mw999@
OPL
assert_empty Maptool::HikingTrails.parse_opl(opl)["features"]
end
def test_parse_opl_carries_the_osm_relation_id_for_cross_country_dedup
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
r1246205 Troute=hiking,type=route,name=Trail Mw1@
OPL
feature = Maptool::HikingTrails.parse_opl(opl)["features"].first
assert_equal 1246205, feature["properties"]["id"]
end
def test_parse_opl_rounds_coordinates_to_5_decimal_places
opl = <<~OPL
w1 Thighway=path Nn10x20.0876543210y49.1791234567,n11x20.0880000001y49.1800000009
r1 Troute=hiking,type=route,name=Trail Mw1@
OPL
coords = Maptool::HikingTrails.parse_opl(opl)["features"].first["geometry"]["coordinates"]
assert_equal [[[20.08765, 49.17912], [20.088, 49.18]]], coords
end
def test_parse_opl_ignores_a_dangling_member_reference_but_keeps_the_rest
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
r1 Troute=hiking,type=route,name=Trail Mw1@,w999@
OPL
features = Maptool::HikingTrails.parse_opl(opl)["features"]
assert_equal 1, features.size
assert_equal 1, features.first["geometry"]["coordinates"].size
end
end
+38 -3
View File
@@ -16,6 +16,7 @@ class LiveTrackClientTest < Minitest::Test
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script> <script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
<script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script> <script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script>
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script> <script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script>
<script>self.__next_f.push([1,"11:{\\"courses\\":[{\\"name\\":\\"40 km okruh\\",\\"geoPoints\\":[{\\"position\\":{\\"lat\\":48.05,\\"lon\\":17.35},\\"distanceMeters\\":0},{\\"position\\":{\\"lat\\":48.06,\\"lon\\":17.36},\\"distanceMeters\\":1000}]}]}"])</script>
</body></html> </body></html>
HTML HTML
@@ -49,18 +50,52 @@ class LiveTrackClientTest < Minitest::Test
assert_equal "Test Ride", summary["session_name"] assert_equal "Test Ride", summary["session_name"]
assert_equal "CYCLING", summary["activity_type"] assert_equal "CYCLING", summary["activity_type"]
assert_equal 5000, summary["distance_meters"] assert_equal 5000, summary["distance_meters"]
refute summary["active"], "session has an end timestamp" refute summary["active"], "last track point is long in the past"
end end
def test_summarize_marks_active_when_session_has_no_end # Not session["end"].nil? (the original signal, before 2026-09-20 — see
# LiveTrackClient's own comment): verified live against a real,
# still-recording session that day, Garmin had already set `end` to
# exactly start + 24h (the share link's own expiry, not an end time),
# so that check called an active session "ended".
def test_summarize_marks_active_when_last_point_is_recent
payloads = @client.send(:parse_payloads, FAKE_PAGE) payloads = @client.send(:parse_payloads, FAKE_PAGE)
points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }) points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) })
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }).merge("end" => nil) points[-1] = points[-1].merge("dateTime" => Time.now.utc.iso8601)
# end present and in the future, same shape Garmin actually sends for a
# session that's still live — active must come from the point, not this.
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] })
.merge("end" => (Time.now.utc + 24 * 60 * 60).iso8601)
result = Maptool::LiveTrackClient::Result.new(points: points, session: session) result = Maptool::LiveTrackClient::Result.new(points: points, session: session)
assert Maptool::LiveTrackClient.summarize(result)["active"] assert Maptool::LiveTrackClient.summarize(result)["active"]
end end
# Verified live the same day: Garmin updates a real session's `end` from
# the future placeholder to within a second of the last point's own
# time immediately once the activity is saved on the watch — so a fresh
# last point alone isn't enough, `end` already being in the past has to
# win even then.
def test_summarize_marks_inactive_once_end_is_in_the_past_even_with_a_fresh_point
payloads = @client.send(:parse_payloads, FAKE_PAGE)
points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) })
points[-1] = points[-1].merge("dateTime" => Time.now.utc.iso8601)
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] })
.merge("end" => (Time.now.utc - 1).iso8601)
result = Maptool::LiveTrackClient::Result.new(points: points, session: session)
refute Maptool::LiveTrackClient.summarize(result)["active"]
end
def test_finds_the_planned_course_alongside_the_actual_track
payloads = @client.send(:parse_payloads, FAKE_PAGE)
course = @client.send(:clean, @client.send(:search, payloads) { |n| n["courses"].first if n["courses"].is_a?(Array) && !n["courses"].empty? })
assert_equal "40 km okruh", course["name"]
assert_equal 2, course["geoPoints"].size
assert_in_delta 48.06, course["geoPoints"].last["position"]["lat"]
end
def test_no_track_points_found_in_a_page_without_them def test_no_track_points_found_in_a_page_without_them
points = @client.send(:search, @client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } points = @client.send(:search, @client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
assert_nil points assert_nil points
+283 -5
View File
@@ -47,6 +47,21 @@ class PmtilesBuilderTest < Minitest::Test
File.write(@pmtiles_fail, "#!/bin/sh\necho 'boom' >&2\nexit 1\n") File.write(@pmtiles_fail, "#!/bin/sh\necho 'boom' >&2\nexit 1\n")
File.chmod(0o755, @pmtiles_fail) File.chmod(0o755, @pmtiles_fail)
# Mimics go-pmtiles' real extract failure mode: hundreds of progress
# lines before the actual error — see command_output's tail-truncation.
@pmtiles_fail_noisy = File.join(@bin_dir, "pmtiles-fail-noisy")
File.write(@pmtiles_fail_noisy, <<~SH)
#!/bin/sh
i=0
while [ "$i" -lt 200 ]; do
echo "fetching $i dirs, $i chunks, 1 requests" >&2
i=$((i + 1))
done
echo "stream error: stream ID 9; INTERNAL_ERROR" >&2
exit 1
SH
File.chmod(0o755, @pmtiles_fail_noisy)
# osmium merge PBF1 PBF2 ... -o OUTPUT — output is always "-o"'s next # osmium merge PBF1 PBF2 ... -o OUTPUT — output is always "-o"'s next
# arg, unlike pmtiles' varying-position output above. # arg, unlike pmtiles' varying-position output above.
@osmium_ok = File.join(@bin_dir, "osmium-ok") @osmium_ok = File.join(@bin_dir, "osmium-ok")
@@ -61,6 +76,27 @@ class PmtilesBuilderTest < Minitest::Test
SH SH
File.chmod(0o755, @osmium_ok) File.chmod(0o755, @osmium_ok)
# Same shape as osmium-ok, but fails on tags-filter specifically (the
# first subcommand HikingTrails.extract runs) while still succeeding
# on merge — lets a single-country build (which never calls merge)
# exercise "hiking-trails extraction failed" without also breaking
# offline.pmtiles' own osmium use in a multi-country test.
@osmium_fail_tags_filter = File.join(@bin_dir, "osmium-fail-tags-filter")
File.write(@osmium_fail_tags_filter, <<~SH)
#!/bin/sh
echo "osmium $1" >> "$PMTILES_LOG"
if [ "$1" = "tags-filter" ]; then
echo "boom" >&2
exit 1
fi
prev=""
for arg; do
if [ "$prev" = "-o" ]; then echo fake > "$arg"; fi
prev="$arg"
done
SH
File.chmod(0o755, @osmium_fail_tags_filter)
# Stands in for `java -jar planetiler.jar --osm-path=... --output=OUT` # Stands in for `java -jar planetiler.jar --osm-path=... --output=OUT`
# — PLANETILER_JAR itself is irrelevant here (this fake ignores -jar's # — PLANETILER_JAR itself is irrelevant here (this fake ignores -jar's
# argument entirely), only JAVA_BIN needs pointing at it. # argument entirely), only JAVA_BIN needs pointing at it.
@@ -102,8 +138,12 @@ class PmtilesBuilderTest < Minitest::Test
# download_extract stub: writes a marker file (same "fake" content the # download_extract stub: writes a marker file (same "fake" content the
# pmtiles/osmium/java fixtures use) instead of hitting Geofabrik, and # pmtiles/osmium/java fixtures use) instead of hitting Geofabrik, and
# returns a fixed Last-Modified so sidecar-meta assertions are stable. # returns a fixed Last-Modified so sidecar-meta assertions are stable.
def fake_download(last_modified: "Wed, 10 Sep 2026 08:00:01 GMT") # `calls` (an Array, shared across builds if the caller wants it) records
->(_url, dest) { # every URL actually downloaded, for asserting the per-country cache
# skipped (or didn't skip) a re-download.
def fake_download(last_modified: "Wed, 10 Sep 2026 08:00:01 GMT", calls: nil)
->(url, dest) {
calls << url if calls
File.write(dest, "fake") File.write(dest, "fake")
last_modified last_modified
} }
@@ -167,15 +207,68 @@ class PmtilesBuilderTest < Minitest::Test
thread.join thread.join
assert_path_exists File.join(@data_dir, "offline.pmtiles") assert_path_exists File.join(@data_dir, "offline.pmtiles")
refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp") refute_path_exists File.join(@data_dir, "offline.tmp.pmtiles")
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state] assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
assert_equal ["planetiler --osm-path"], log_lines assert_equal ["planetiler --osm-path", "osmium tags-filter", "osmium add-locations-to-ways", "osmium cat"], log_lines
assert_equal( assert_equal(
{ "sources" => [{ "code" => "SK", "slug" => "europe/slovakia", "last_modified" => "Wed, 10 Sep 2026 08:00:01 GMT" }] }, { "sources" => [{ "code" => "SK", "slug" => "europe/slovakia", "last_modified" => "Wed, 10 Sep 2026 08:00:01 GMT" }] },
Maptool::PmtilesBuilder.offline_meta(@data_dir), Maptool::PmtilesBuilder.offline_meta(@data_dir),
) )
end end
def test_build_offline_also_writes_a_per_country_hiking_trails_geojson
select_regions("SK")
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join
# The fake osmium binary doesn't produce real OPL output, just
# confirms the file lands (one per country) and is valid (empty)
# GeoJSON — the real parsing logic is covered by test_hiking_trails.rb
# against real OPL fixtures.
assert_equal ["SK"], Maptool::PmtilesBuilder.hiking_trails_countries(@data_dir)
path = Maptool::PmtilesBuilder.hiking_trails_country_path(@data_dir, "SK")
assert_equal({ "type" => "FeatureCollection", "features" => [] }, JSON.parse(File.read(path)))
end
def test_build_offline_only_re_extracts_hiking_trails_for_a_newly_added_country
select_regions("SK")
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
File.write(@log, "")
select_regions("SK", "AT")
Maptool::PmtilesBuilder.build_offline(
data_dir: @data_dir,
download_extract: fake_download,
fetch_last_modified: ->(_url) { "Wed, 10 Sep 2026 08:00:01 GMT" },
).join
# SK's cached extract is already newer than SK.osm.pbf (untouched
# since the first build), so only AT's hiking trails get (re-)extracted
# — SK.osm.pbf itself is also skipped by cached_extract's own
# Last-Modified check, "osmium merge" runs regardless since two
# countries are now selected.
assert_equal(
["osmium merge", "planetiler --osm-path", "osmium tags-filter", "osmium add-locations-to-ways", "osmium cat"],
log_lines,
)
assert_equal %w[AT SK], Maptool::PmtilesBuilder.hiking_trails_countries(@data_dir)
end
# The actual reasoning: hiking trails are a bonus overlay bolted onto the
# existing offline.pmtiles build, not the core map — an osmium hiccup on
# this one step shouldn't take down an otherwise-successful build.
def test_build_offline_still_succeeds_when_hiking_trails_extraction_fails
select_regions("SK")
ENV["OSMIUM_BIN"] = @osmium_fail_tags_filter
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join
assert_path_exists File.join(@data_dir, "offline.pmtiles")
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
refute_path_exists File.join(@data_dir, "hiking_trails.geojson")
refute_path_exists "#{File.join(@data_dir, "hiking_trails.geojson")}.tmp"
end
# Two countries selected — osmium merge should run before planetiler, # Two countries selected — osmium merge should run before planetiler,
# each downloaded source recorded in the sidecar meta. # each downloaded source recorded in the sidecar meta.
def test_build_offline_merges_multiple_selected_countries def test_build_offline_merges_multiple_selected_countries
@@ -184,11 +277,180 @@ class PmtilesBuilderTest < Minitest::Test
thread.join thread.join
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state] assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
assert_equal ["osmium merge", "planetiler --osm-path"], log_lines assert_equal(
[
"osmium merge", "planetiler --osm-path",
"osmium tags-filter", "osmium add-locations-to-ways", "osmium cat", # SK's own hiking-trails extraction
"osmium tags-filter", "osmium add-locations-to-ways", "osmium cat", # AT's own
],
log_lines,
)
codes = Maptool::PmtilesBuilder.offline_meta(@data_dir)["sources"].map { |s| s["code"] } codes = Maptool::PmtilesBuilder.offline_meta(@data_dir)["sources"].map { |s| s["code"] }
assert_equal %w[SK AT], codes assert_equal %w[SK AT], codes
end end
# The actual bug report this covers: hitting "Apply" after tweaking the
# region selection used to re-download every selected country's .osm.pbf
# from scratch, even ones already fetched by an earlier build.
def test_build_offline_skips_redownloading_an_unchanged_cached_country
select_regions("SK")
calls = []
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download(calls: calls)).join
assert_equal 1, calls.size
Maptool::PmtilesBuilder.build_offline(
data_dir: @data_dir,
download_extract: fake_download(calls: calls),
fetch_last_modified: ->(_url) { "Wed, 10 Sep 2026 08:00:01 GMT" },
).join
assert_equal 1, calls.size
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
end
def test_build_offline_redownloads_a_country_whose_extract_changed
select_regions("SK")
calls = []
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download(calls: calls)).join
Maptool::PmtilesBuilder.build_offline(
data_dir: @data_dir,
download_extract: fake_download(calls: calls, last_modified: "Thu, 11 Sep 2026 08:00:01 GMT"),
fetch_last_modified: ->(_url) { "Thu, 11 Sep 2026 08:00:01 GMT" },
).join
assert_equal 2, calls.size
end
# A cache-freshness check that itself fails (Geofabrik unreachable) should
# fall back to re-downloading rather than aborting the build.
def test_build_offline_redownloads_when_the_cache_check_fails
select_regions("SK")
calls = []
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download(calls: calls)).join
fetch_last_modified = ->(_url) { raise Maptool::PmtilesBuilder::BuildError, "could not reach geofabrik" }
Maptool::PmtilesBuilder.build_offline(
data_dir: @data_dir,
download_extract: fake_download(calls: calls),
fetch_last_modified: fetch_last_modified,
).join
assert_equal 2, calls.size
assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state]
end
def test_build_offline_only_downloads_a_newly_added_country
select_regions("SK")
calls = []
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download(calls: calls)).join
select_regions("SK", "AT")
Maptool::PmtilesBuilder.build_offline(
data_dir: @data_dir,
download_extract: fake_download(calls: calls),
fetch_last_modified: ->(_url) { "Wed, 10 Sep 2026 08:00:01 GMT" },
).join
assert_equal 1, calls.count { |url| url.include?("slovakia") }
assert_equal 1, calls.count { |url| url.include?("austria") }
end
# offline_status[:step] should say which country is in flight right now
# and which of the current build's selection are already done/still
# pending — not just "running since X".
def test_build_offline_reports_progress_step_while_running
select_regions("SK", "AT")
started = Queue.new
gate = Queue.new
download_extract = ->(url, dest) {
started.push(url)
gate.pop
File.write(dest, "fake")
"Wed, 10 Sep 2026 08:00:01 GMT"
}
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: download_extract)
started.pop
assert_equal "fetching SK (1/2) — pending: AT", Maptool::PmtilesBuilder.offline_status[:step]
gate.push(:go)
started.pop
assert_equal "fetching AT (2/2) — done: SK", Maptool::PmtilesBuilder.offline_status[:step]
gate.push(:go)
thread.join
assert_nil Maptool::PmtilesBuilder.offline_status[:step]
end
def test_delete_offline_data_removes_the_file_meta_and_cache
select_regions("SK")
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
assert_path_exists File.join(@data_dir, "offline.pmtiles")
assert_path_exists File.join(@data_dir, "geofabrik_cache", "SK.osm.pbf")
Maptool::PmtilesBuilder.delete_offline_data(@data_dir)
refute_path_exists File.join(@data_dir, "offline.pmtiles")
refute_path_exists File.join(@data_dir, "geofabrik_cache")
assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir)
end
def test_delete_offline_data_is_a_noop_when_nothing_was_built
Maptool::PmtilesBuilder.delete_offline_data(@data_dir) # should not raise
end
def test_delete_offline_data_raises_while_a_build_is_running
select_regions("SK")
gate = Queue.new
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: ->(_url, _dest) { gate.pop })
assert_raises(Maptool::PmtilesBuilder::BuildError) { Maptool::PmtilesBuilder.delete_offline_data(@data_dir) }
ensure
gate.push("unused")
thread&.join
end
def test_delete_cached_extract_removes_only_that_country
select_regions("SK", "AT")
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
Maptool::PmtilesBuilder.delete_cached_extract(@data_dir, "SK")
refute_path_exists File.join(@data_dir, "geofabrik_cache", "SK.osm.pbf")
assert_path_exists File.join(@data_dir, "geofabrik_cache", "AT.osm.pbf")
end
def test_delete_terrain_data_removes_the_file_and_meta
select_regions("ES")
ENV["PMTILES_BIN"] = @pmtiles_ok
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
thread.join
assert_path_exists File.join(@data_dir, "terrain.pmtiles")
Maptool::PmtilesBuilder.delete_terrain_data(@data_dir)
refute_path_exists File.join(@data_dir, "terrain.pmtiles")
assert_nil Maptool::PmtilesBuilder.terrain_meta(@data_dir)
end
def test_cached_extracts_is_empty_before_any_download
assert_empty Maptool::PmtilesBuilder.cached_extracts(@data_dir)
end
# Deliberately still selected — cached_extracts reflects the cache on
# disk, not the current selection or the last build's own meta (see
# cached_extracts' own comment for why those three can all disagree).
def test_cached_extracts_lists_what_was_downloaded
select_regions("SK", "AT")
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
extracts = Maptool::PmtilesBuilder.cached_extracts(@data_dir)
assert_equal %w[AT SK], extracts.map { |e| e["code"] }
assert_equal [4, 4], extracts.map { |e| e["size"] } # "fake".bytesize
assert_equal ["Wed, 10 Sep 2026 08:00:01 GMT"] * 2, extracts.map { |e| e["last_modified"] }
end
def test_offline_meta_is_nil_before_any_build def test_offline_meta_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir) assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir)
end end
@@ -379,6 +641,22 @@ class PmtilesBuilderTest < Minitest::Test
assert_match(/exited with status/, status[:message]) assert_match(/exited with status/, status[:message])
end end
# The real trigger for this: a Mapterhorn extract that failed after ~7000
# progress lines, producing a 500k+ character status[:message] that was
# unreadable in the web UI.
def test_build_terrain_truncates_a_noisy_command_failure
select_regions("ES")
ENV["PMTILES_BIN"] = @pmtiles_fail_noisy
thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
thread.join
status = Maptool::PmtilesBuilder.terrain_status
assert_equal "error", status[:state]
assert_match(/\(\d+ earlier lines omitted\)/, status[:message])
assert_match(/stream error: stream ID 9/, status[:message])
assert_operator status[:message].length, :<, 5000
end
def test_build_terrain_raises_when_one_is_already_running def test_build_terrain_raises_when_one_is_already_running
select_regions("ES") select_regions("ES")
ENV["PMTILES_BIN"] = @pmtiles_ok ENV["PMTILES_BIN"] = @pmtiles_ok
+5
View File
@@ -31,6 +31,11 @@
<div class="profile-panel__charts"></div> <div class="profile-panel__charts"></div>
</div> </div>
<div id="trail-panel" class="trail-panel" hidden>
<button type="button" class="trail-panel__close" aria-label="Close">&times;</button>
<div class="trail-panel__body"></div>
</div>
<div id="basemap-panel" class="basemap-panel" hidden> <div id="basemap-panel" class="basemap-panel" hidden>
<div class="basemap-panel__header"> <div class="basemap-panel__header">
<strong>Base map</strong> <strong>Base map</strong>
+1
View File
@@ -35,6 +35,7 @@
"/js/dem.js": "/js/dem.js?v=<%= ASSET_VERSION %>", "/js/dem.js": "/js/dem.js?v=<%= ASSET_VERSION %>",
"/js/gnss.js": "/js/gnss.js?v=<%= ASSET_VERSION %>", "/js/gnss.js": "/js/gnss.js?v=<%= ASSET_VERSION %>",
"/js/gpx.js": "/js/gpx.js?v=<%= ASSET_VERSION %>", "/js/gpx.js": "/js/gpx.js?v=<%= ASSET_VERSION %>",
"/js/hiking_trails.js": "/js/hiking_trails.js?v=<%= ASSET_VERSION %>",
"/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>", "/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>",
"/js/livetrack.js": "/js/livetrack.js?v=<%= ASSET_VERSION %>", "/js/livetrack.js": "/js/livetrack.js?v=<%= ASSET_VERSION %>",
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>", "/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",