Compare commits

...
25 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
30 changed files with 5006 additions and 52 deletions

No files matched your search

+7
View File
@@ -6,6 +6,13 @@ on:
jobs: jobs:
check: check:
# 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 runs-on: brick-build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+3
View File
@@ -8,6 +8,9 @@ on:
jobs: jobs:
renovate: renovate:
# Same reasoning as check's runs-on in ci.yml: brick-build's job
# container now has Node (repointed at Gitea's own official runner
# image), which setup-node/npm below need.
runs-on: brick-build runs-on: brick-build
steps: steps:
- name: Setup Node - name: Setup Node
+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
+2
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
+93 -18
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
@@ -356,6 +391,34 @@ module Maptool
json ok: true json ok: true
end 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
@@ -680,24 +743,36 @@ 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",
{ geometry: { type: "Point", coordinates: coordinates.last },
type: "Feature", # Merges the session summary in too — the point layer's hover
geometry: { type: "Point", coordinates: coordinates.last }, # popup (public/js/livetrack.js) reads it straight off here
# Merges the session summary in too — the point layer's hover # rather than needing a second fetch against /sessions.
# popup (public/js/livetrack.js) reads it straight off here properties: summary.merge(
# rather than needing a second fetch against /sessions. "time" => last["dateTime"],
properties: summary.merge( "speed_meters_per_sec" => last["speedMetersPerSec"],
"time" => last["dateTime"], "heart_rate_beats_per_min" => last["heartRateBeatsPerMin"]
"speed_meters_per_sec" => last["speedMetersPerSec"], ),
"heart_rate_beats_per_min" => last["heartRateBeatsPerMin"] },
), ]
},
] # 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
+4
View File
@@ -125,3 +125,7 @@ services:
volumes: volumes:
# :Z, same SELinux reasoning as maptool's own ./data mount above. # :Z, same SELinux reasoning as maptool's own ./data mount above.
- ./data/kiwix/wikipedia.zim:/data/wikipedia.zim:Z - ./data/kiwix/wikipedia.zim:/data/wikipedia.zim:Z
networks:
default:
enable_ipv6: true
+20 -5
View File
@@ -9,7 +9,17 @@ against the Gitea instance:
* The existing Docker-based runner (`gitea/runner:3`), label * The existing Docker-based runner (`gitea/runner:3`), label
`brick-build` — runs the `check` and `renovate` jobs in a fresh, `brick-build` — runs the `check` and `renovate` jobs 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 `brick-host` — runs only the `deploy` job. around it), label `brick-host` — runs only the `deploy` job.
@@ -202,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
@@ -225,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",
+65 -2
View File
@@ -71,10 +71,10 @@ 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 --overfetch=0 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 --overfetch=0 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
---- ----
@@ -88,11 +88,74 @@ fragile mega-request for many small ones instead (spread over
connection only loses one small request's worth of progress, not the connection only loses one small request's worth of progress, not the
whole extract. 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
+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,
+14
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):
+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"
+65 -5
View File
@@ -5,6 +5,7 @@ require "json"
require "tmpdir" require "tmpdir"
require "fileutils" require "fileutils"
require "etc" 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
@@ -88,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" }
@@ -285,12 +290,66 @@ module Maptool
run_planetiler!("--osm-path=#{osm_path}", "--output=#{tmp_path}", "--download", "--threads=#{Etc.nprocessors}") 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 — # 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" # 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 # but which of the current build's sources is in flight and which are
@@ -438,6 +497,7 @@ module Maptool
File.delete(File.join(data_dir, "offline.pmtiles")) if File.exist?(File.join(data_dir, "offline.pmtiles")) 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)) 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) cache_dir = geofabrik_cache_dir(data_dir)
FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir) FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir)
end end
+86
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;
} }
@@ -1354,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;
@@ -1571,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 },
}); });
+5
View File
@@ -607,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`;
} }
+9 -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
@@ -184,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
@@ -217,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"));
@@ -241,6 +245,10 @@ 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 // Called by map.js's PitchToggleControl whenever the 3D pitch toggle
// changes, so raised terrain follows the camera tilt rather than needing // changes, so raised terrain follows the camera tilt rather than needing
// the "Terrain shading" checkbox re-clicked to notice. // the "Terrain shading" checkbox re-clicked to notice.
+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
+13
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))
+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
+83 -2
View File
@@ -76,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.
@@ -188,13 +209,66 @@ class PmtilesBuilderTest < Minitest::Test
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.tmp.pmtiles") 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
@@ -203,7 +277,14 @@ 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
+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 %>",