Compare commits

...
17 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
26 changed files with 4347 additions and 267 deletions

No files matched your search

+8 -9
View File
@@ -6,15 +6,14 @@ on:
jobs:
check:
# Not brick-build: that label's job container (opensuse/leap:16.0) has
# no Node.js, and every step below (checkout, setup-ruby, setup-node)
# is a JS-based Action that needs it to even start — see the run that
# failed immediately on 2026-09-12. ubuntu-latest is shared with
# "Gitea Runner on Cube" too (not pinned to this machine specifically),
# but that's fine here: only deploy (runs-on: brick-host, a unique
# label held by just the native runner on brick) actually needs to
# land on a specific box.
runs-on: ubuntu-latest
# brick-build's job container now points at Gitea's own official
# runner image (docker.gitea.com/runner-images:ubuntu-latest, has
# Node out of the box) — the earlier opensuse/leap:16.0 image that
# failed here on 2026-09-12 (no Node, every step below is a JS-based
# Action) got repointed. Pinned to brick specifically again, rather
# than the shared ubuntu-latest label "Gitea Runner on Cube" also
# answers to.
runs-on: brick-build
steps:
- uses: actions/checkout@v4
+3 -2
View File
@@ -9,8 +9,9 @@ on:
jobs:
renovate:
# Same reasoning as check's runs-on in ci.yml: brick-build's job
# container has no Node, which setup-node/npm below need.
runs-on: ubuntu-latest
# container now has Node (repointed at Gitea's own official runner
# image), which setup-node/npm below need.
runs-on: brick-build
steps:
- name: Setup Node
uses: actions/setup-node@v4
+1 -2
View File
@@ -13,8 +13,7 @@ independence from connectivity.
** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] 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. Generated automatically as the last step of every offline build (`PmtilesBuilder.extract_hiking_trails`, best-effort — a failure here doesn't fail the whole build) into `data/hiking_trails.geojson`, served at `GET /tiles/hiking_trails.geojson` only once the overlay's actually toggled on. Offline click uses a local `queryRenderedFeatures` hit-test instead of Overpass — instant, no network. See docs/development.adoc
*** [ ] File size for a many-country selection could get large (Slovakia alone: ~3000 routes) — coordinates are rounded to 5dp but there's no real tiling/simplification yet; revisit if a big selection makes the browser fetch noticeably slow
** [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] 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
+83 -33
View File
@@ -15,6 +15,40 @@ require_relative "lib/maptool/plugins"
require_relative "lib/maptool/pmtiles_builder"
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
set :public_folder, File.join(__dir__, "public")
set :views, File.join(__dir__, "views")
@@ -33,6 +67,7 @@ module Maptool
# don't survive a restart there, which is fine outside production.
set :sessions, httponly: true, secure: production?, same_site: :lax,
secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) }
use SkipSessionWrite
# Cache-busts /js and /css requests: browsers (and a caching proxy in
# front of production, see docs/deployment.adoc) hang onto static
@@ -62,7 +97,6 @@ module Maptool
OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles")
TERRAIN_PMTILES_PATH = File.join(DATA_DIR, "terrain.pmtiles")
HIKING_TRAILS_PATH = PmtilesBuilder.hiking_trails_path(DATA_DIR)
GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json"))
@@ -361,24 +395,28 @@ module Maptool
# 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 or delete; DELETE /api/offline-tiles removes this
# file too. No `meta` argument here (unlike offline/terrain above) —
# parsing the whole GeoJSON just to report a feature count would cost
# real time on every page load's status check for what's a multi-MB
# file across several countries; size/mtime alone is enough to know
# whether it's there.
# 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 pmtiles_file_status(HIKING_TRAILS_PATH, nil)
json countries: PmtilesBuilder.hiking_trails_countries(DATA_DIR)
end
# Fetched client-side only when the Hiking trails overlay is actually
# toggled on (public/js/hiking_trails.js), not on page load — same
# "on demand" story as the online raster tiles it replaces once this
# is available.
get "/tiles/hiking_trails.geojson" do
halt 404 unless File.exist?(HIKING_TRAILS_PATH)
# 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/)
send_file HIKING_TRAILS_PATH, type: "application/geo+json"
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
@@ -705,24 +743,36 @@ module Maptool
halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last
json(
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
{
type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last },
# Merges the session summary in too — the point layer's hover
# popup (public/js/livetrack.js) reads it straight off here
# rather than needing a second fetch against /sessions.
properties: summary.merge(
"time" => last["dateTime"],
"speed_meters_per_sec" => last["speedMetersPerSec"],
"heart_rate_beats_per_min" => last["heartRateBeatsPerMin"]
),
},
]
)
features = [
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: { "kind" => "actual" } },
{
type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last },
# Merges the session summary in too — the point layer's hover
# popup (public/js/livetrack.js) reads it straight off here
# rather than needing a second fetch against /sessions.
properties: summary.merge(
"time" => last["dateTime"],
"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
get "/api/gnss/position" do
+4
View File
@@ -125,3 +125,7 @@ services:
volumes:
# :Z, same SELinux reasoning as maptool's own ./data mount above.
- ./data/kiwix/wikipedia.zim:/data/wikipedia.zim:Z
networks:
default:
enable_ipv6: true
+22 -14
View File
@@ -7,16 +7,19 @@ MapTool deploys as a Podman container (via `podman compose`, driven by a
the Gitea Actions runners run on. Two separate runners are registered
against the Gitea instance:
* The existing Docker-based runner (`gitea/runner:3`), labels
`ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04` — runs the `check` and
`renovate` jobs in a fresh, isolated container per job, same as
before. It's also registered under `brick-build`, but that label's
job container (`opensuse/leap:16.0`) has no Node.js, which every step
in `check`/`renovate` needs (they're JS-based Actions) — until that
image gets Node, or the label points elsewhere, `check`/`renovate`
stay on `ubuntu-latest`. That label is shared with "Gitea Runner on
Cube" too, so a `check`/`renovate` run isn't pinned to this specific
machine — harmless, since only `deploy` actually needs a specific box.
* The existing Docker-based runner (`gitea/runner:3`), label
`brick-build` — runs the `check` and `renovate` jobs in a fresh,
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
around it), label `brick-host` — runs only the `deploy` job.
@@ -209,7 +212,7 @@ const PROCESSED_LABEL = "livetrack-processed";
function checkLiveTrackEmail() {
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 => {
// getBody() (raw HTML), not getPlainBody(): the email has no text/plain
@@ -232,9 +235,14 @@ function checkLiveTrackEmail() {
The Gmail label (rather than marking the message read/unread) tracks which
threads are already handled without touching the user's own inbox state.
`subject:LiveTrack` matches regardless of the account's language — Garmin
keeps "LiveTrack" as-is in the subject even in a localized
("Sledujte LiveTrack používateľa ...") one.
No `subject:LiveTrack` filter on the search — despite Gmail's own
substring-match documentation for `subject:`, that filter empirically
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
Trigger* → function `checkLiveTrackEmail`, time-driven, "Minutes timer",
+29 -18
View File
@@ -106,11 +106,12 @@ size rather than assuming one, so this isn't a problem either way.
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, `data/hiking_trails.geojson`
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:
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]
----
@@ -130,20 +131,30 @@ tool stitches a route relation's member ways into one line for you
waymarkedtrails.org and this app's own online Overpass click handler
already do.
Best-effort: a hiking-trails extraction problem on one country's PBF
logs a warning and is skipped rather than failing the whole
`offline.pmtiles` build over what's a bonus overlay, not the core map
(`PmtilesBuilder.extract_hiking_trails`). Coordinates are rounded to 5
decimal places (~1m, plenty for a rendered line) to keep the combined
file smaller — for a many-country selection it can still be a sizeable
GeoJSON (Slovakia alone: ~3000 routes, ~30MB unrounded), fetched
client-side only once the overlay is actually toggled on, not on page
load. `rake hiking:trails[pbf,out]` runs the extraction by hand against
one `.osm.pbf` for testing.
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.
`DELETE /api/offline-tiles` removes this file along with `offline.pmtiles`
and the Geofabrik cache — there's no separate delete/rebuild route for it,
since it's not an independent build.
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
+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
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)
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
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
Lipiridis), attribution required (this notice satisfies that):
-16
View File
@@ -63,22 +63,6 @@ module Maptool
end
end
# Runs extract() over one .osm.pbf per selected country and combines the
# results into a single FeatureCollection — a route relation crossing a
# border (an E-path, say) shows up once per country whose extract it
# was pulled from, deduplicated here by OSM relation id (properties.id)
# rather than left to render as overlapping duplicate lines.
# extract: same injectable-seam reasoning as PmtilesBuilder's own
# download_extract:/fetch_manifest: — lets a test swap out the real
# (osmium-shelling-out) implementation for a canned one.
def self.extract_merged(pbf_paths, extract: method(:extract))
seen = {}
pbf_paths.each do |pbf_path|
extract.call(pbf_path)["features"].each { |feature| seen[feature["properties"]["id"]] ||= feature }
end
{ "type" => "FeatureCollection", "features" => seen.values }
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
+47 -7
View File
@@ -1,5 +1,6 @@
require "net/http"
require "json"
require "time"
module Maptool
# 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"
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)
new.fetch(session_id, token)
@@ -29,6 +30,10 @@ module Maptool
# if that part wasn't found even though points were (seen in practice:
# session details are consistently present alongside points, but nothing
# 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)
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
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
# Distance/duration/activity type come from the last track point (its
# totals are cumulative for the whole session), name/type from the
# session details — nil-safe throughout since either can be missing.
#
# "active": Garmin doesn't set a session's `end` until the activity is
# actually stopped, so its absence means the session is still recording
# — inferred from the one (already-ended) session this was built
# against, not verified against a session actually caught mid-activity.
# "active": originally `session["end"].nil?`, on the assumption Garmin
# doesn't set `end` until the activity actually stops — disproven
# against a real still-recording session (2026-09-20): `end` was
# 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)
last = result.points.last || {}
session = result.session || {}
@@ -56,12 +85,23 @@ module Maptool
"activity_type" => last["activityType"],
"distance_meters" => last["totalDistanceMeters"],
"duration_secs" => last["totalDurationSecs"],
"active" => session["end"].nil?,
"active" => active?(last["dateTime"], session["end"]),
"start" => session["start"],
"end" => session["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
# The RSC payload serializes an absent field as the literal string
+26 -3
View File
@@ -12,7 +12,15 @@ module Maptool
module OverpassPois
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
# 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).
def self.fetch(bbox, timeout: 60)
query = build_query(bbox, timeout: timeout)
uri = URI(OVERPASS_API)
response = Net::HTTP.post_form(uri, "data" => query)
errors = OVERPASS_APIS.map do |url|
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)
JSON.parse(response.body)
+7 -2
View File
@@ -65,8 +65,13 @@ module Maptool
south, west, north, east = bbox
args = ["--south=#{south}", "--west=#{west}", "--north=#{north}", "--east=#{east}"]
stdout, _stderr, status = Open3.capture3(command, *args, chdir: plugin_dir)
raise RunError, "plugin exited with status #{status.exitstatus}" unless status.success?
stdout, stderr, status = Open3.capture3(command, *args, chdir: plugin_dir)
# 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)
raise RunError, "plugin did not print a GeoJSON FeatureCollection" unless geojson.is_a?(Hash) && geojson["type"] == "FeatureCollection"
+45 -16
View File
@@ -299,26 +299,55 @@ module Maptool
end
private_class_method :extract_offline
# Runs over the same per-country .osm.pbf files sources above already
# 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.
# Best-effort: a hiking-trails extraction problem (a malformed PBF
# region, osmium hiccup on one particular file) shouldn't fail the
# whole offline.pmtiles build over what's a bonus overlay, not the
# core map — logged into the sidecar meta's own "error" field instead,
# so it's visible without taking build_offline itself down.
# 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)
geojson = HikingTrails.extract_merged(sources.map { |s| s["path"] })
tmp_path = "#{hiking_trails_path(data_dir)}.tmp"
File.write(tmp_path, JSON.generate(geojson))
File.rename(tmp_path, hiking_trails_path(data_dir))
rescue HikingTrails::ExtractError => e
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
warn "hiking trails extraction failed, skipping: #{e.message}"
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_path(data_dir)
File.join(data_dir, "hiking_trails.geojson")
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 —
@@ -468,7 +497,7 @@ module Maptool
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(hiking_trails_path(data_dir)) if File.exist?(hiking_trails_path(data_dir))
FileUtils.remove_entry(hiking_trails_dir(data_dir)) if Dir.exist?(hiking_trails_dir(data_dir))
cache_dir = geofabrik_cache_dir(data_dir)
FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir)
end
+78
View File
@@ -397,6 +397,68 @@ html, body {
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 {
padding: 8px 12px;
}
@@ -1579,6 +1641,22 @@ html, body {
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 {
flex: 1;
overflow-y: auto;
+256 -112
View File
@@ -1,11 +1,8 @@
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
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";
const SOURCE_ID = "hiking-trails";
const LAYER_ID = "hiking-trails-line";
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
@@ -34,22 +31,42 @@ 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 when there's
// no local offline extraction to draw from instead (see _addOfflineLayer
// below) — same "quietly renders nothing without a network" story as the
// Wikipedia/Radar/Contour lines overlays.
// 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: a real vector line layer, 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_offline) — no network needed at all, and no
// waiting on Overpass for click details either (queried locally via
// queryRenderedFeatures instead, see _handleClick below).
const OFFLINE_GEOJSON_URL = "/tiles/hiking_trails.geojson";
// 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
@@ -66,76 +83,191 @@ function normalizedTrailColor(raw) {
return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : DEFAULT_TRAIL_COLOR;
}
// A plain on/off Overlays item, same shape as ContourControl — one static
// raster overlay, no scrub/forecast controls to expand into.
// 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;
this._offlineAvailable = false;
this._clickHandler = (event) => this._handleClick(event);
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 this back if it was on, same as every other Overlays
// control.
// 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._addLayer();
if (this._active) this._addOnlineLayer();
this._clearHighlight();
if (this._selectedCountries.size > 0) this._rebuildLanes();
});
}
// Called once map.js's GET /api/hiking-trails check resolves — same
// "prefer the local file whenever it exists" story as
// OverlaysControl#setHillshadeOfflineAvailable, independent of whether
// the Online or Offline *base map* happens to be selected right now.
setOfflineAvailable(available) {
this._offlineAvailable = available;
}
mount(li) {
li.innerHTML = `
<label>
<div class="overlays-panel__row">
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${WALKING_ICON}</span>
<span>Hiking trails</span>
</label>
<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._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked));
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));
});
}
_toggle(active) {
// 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._addLayer();
this._map.on("click", this._clickHandler);
} else {
this._removeLayer();
this._map.off("click", this._clickHandler);
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._offlineAvailable) {
this._handleOfflineClick(event);
} else {
this._handleOnlineClick(event);
}
if (this._selectedCountries.size > 0) this._handleOfflineClick(event);
else if (this._active) this._handleOnlineClick(event);
}
// The local vector layer has real features to hit-test directly — no
// network round-trip, so this resolves instantly. A genuine miss (empty
// result) shows no popup at all rather than a "not found" message, same
// as clicking empty space anywhere else on the map.
// 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 features = this._map.queryRenderedFeatures(event.point, { layers: [LAYER_ID] });
if (features.length === 0) return;
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));
}
const seen = new Set();
const html = features
.filter((feature) => !seen.has(feature.properties.id) && seen.add(feature.properties.id))
.map((feature) => routeHtml(feature.properties))
.join("<hr>");
new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(html).addTo(this._map);
_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
@@ -145,65 +277,34 @@ export class HikingTrailsControl {
// pixel-thick) rendered line.
async _handleOnlineClick(event) {
const { lng, lat } = event.lngLat;
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
.setLngLat(event.lngLat)
.setHTML(`<p class="gpx-popup__category">Looking for trails…</p>`)
.addTo(this._map);
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 {
popup.setHTML(`<p class="gpx-popup__category">Could not reach Overpass.</p>`);
if (this._onlineRequest === request) this._panelBody.innerHTML = `<p class="gpx-popup__category">Could not reach Overpass.</p>`;
return;
}
if (this._onlineRequest !== request) return;
popup.setHTML(
relations.length > 0
? relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>")
: `<p class="gpx-popup__category">No hiking trail found here.</p>`,
);
}
async _addLayer() {
if (this._map.getSource(SOURCE_ID)) return;
if (this._offlineAvailable) await this._addOfflineLayer();
else this._addOnlineLayer();
}
async _addOfflineLayer() {
let geojson;
try {
geojson = await (await fetch(OFFLINE_GEOJSON_URL)).json();
} catch {
return; // degrade quietly, same story as every other optional layer here
if (relations.length === 0) {
this._panelBody.innerHTML = `<p class="gpx-popup__category">No hiking trail found here.</p>`;
return;
}
if (this._map.getSource(SOURCE_ID)) return; // toggled off and back on again while this was fetching
// Normalized once here (rather than a MapLibre expression in the paint
// property below) so an untrustworthy `colour` tag value never reaches
// a style property 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._map.addSource(SOURCE_ID, { type: "geojson", data: geojson });
this._map.addLayer(
{
id: LAYER_ID,
type: "line",
source: SOURCE_ID,
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": ["get", "color"], "line-width": 2 },
},
firstSymbolLayerId(this._map),
);
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() {
this._map.addSource(SOURCE_ID, {
if (this._map.getSource(ONLINE_SOURCE_ID)) return;
this._map.addSource(ONLINE_SOURCE_ID, {
type: "raster",
tiles: [TILE_URL],
tileSize: 256,
@@ -213,14 +314,46 @@ export class HikingTrailsControl {
// 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: LAYER_ID, type: "raster", source: SOURCE_ID, paint: { "raster-opacity": OPACITY } },
{ id: ONLINE_LAYER_ID, type: "raster", source: ONLINE_SOURCE_ID, paint: { "raster-opacity": OPACITY } },
firstSymbolLayerId(this._map),
);
}
_removeLayer() {
if (this._map.getLayer(LAYER_ID)) this._map.removeLayer(LAYER_ID);
if (this._map.getSource(SOURCE_ID)) this._map.removeSource(SOURCE_ID);
_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);
}
}
@@ -254,10 +387,21 @@ function routeDistance(relation) {
.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 popup builder below serves
// both instead of two near-identical ones.
// 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 {
+17 -1
View File
@@ -5,6 +5,11 @@ import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
const SESSIONS_URL = "/api/livetrack/sessions";
const TRACK_SOURCE = "livetrack-track";
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
// in the session data) — polling twice that often would just re-fetch the
// same 400+KB page for nothing most of the time.
@@ -171,11 +176,22 @@ export class LiveTrackPanelControl {
if (this._map.getSource(TRACK_SOURCE)) return;
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({
id: `${TRACK_SOURCE}-line`,
type: "line",
source: TRACK_SOURCE,
filter: ["==", ["geometry-type"], "LineString"],
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "actual"]],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
});
+1 -1
View File
@@ -609,7 +609,7 @@ fetch("/api/terrain-tiles")
fetch("/api/hiking-trails")
.then((response) => response.json())
.then(({ available }) => overlaysControl.setHikingTrailsOfflineAvailable(available))
.then(({ countries }) => overlaysControl.setHikingTrailsOfflineCountries(countries))
.catch(() => {});
fetch("/api/kiwix")
+3 -3
View File
@@ -219,7 +219,7 @@ 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"></li>
<li class="overlays-panel__item"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
`;
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem, hikingTrailsItem] = list.children;
this._hillshade.mount(hillshadeItem);
@@ -245,8 +245,8 @@ export class OverlaysControl {
this._hillshade.setOfflineAvailable(available);
}
setHikingTrailsOfflineAvailable(available) {
this._hikingTrails.setOfflineAvailable(available);
setHikingTrailsOfflineCountries(countries) {
this._hikingTrails.setOfflineCountries(countries);
}
// Called by map.js's PitchToggleControl whenever the 3D pitch toggle
+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
+8 -4
View File
@@ -39,13 +39,17 @@ class AppTest < Minitest::Test
assert_equal({ "available" => false }, json_body(response))
end
def test_hiking_trails_reports_unavailable_when_no_geojson_present
def test_hiking_trails_reports_no_countries_when_none_extracted_yet
response = request.get("/api/hiking-trails")
assert_equal({ "available" => false }, json_body(response))
assert_equal({ "countries" => [] }, json_body(response))
end
def test_hiking_trails_file_404s_when_no_geojson_present
assert_equal 404, request.get("/tiles/hiking_trails.geojson").status
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
-15
View File
@@ -111,21 +111,6 @@ class HikingTrailsTest < Minitest::Test
assert_equal [[[20.08765, 49.17912], [20.088, 49.18]]], coords
end
def test_extract_merged_deduplicates_by_relation_id_across_files
feature = ->(id) {
{ "type" => "Feature", "properties" => { "id" => id, "name" => "Trail #{id}" }, "geometry" => { "type" => "MultiLineString", "coordinates" => [] } }
}
# Relation 1 shows up in both "country" files (a cross-border trail);
# relation 2 only in the second.
per_file = { "at.osm.pbf" => [feature.call(1)], "cz.osm.pbf" => [feature.call(1), feature.call(2)] }
extract = ->(pbf_path) { { "type" => "FeatureCollection", "features" => per_file.fetch(pbf_path) } }
merged = Maptool::HikingTrails.extract_merged(%w[at.osm.pbf cz.osm.pbf], extract: extract)
ids = merged["features"].map { |f| f["properties"]["id"] }.sort
assert_equal [1, 2], ids
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
+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,"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,"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>
HTML
@@ -49,18 +50,52 @@ class LiveTrackClientTest < Minitest::Test
assert_equal "Test Ride", summary["session_name"]
assert_equal "CYCLING", summary["activity_type"]
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
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)
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)
assert Maptool::LiveTrackClient.summarize(result)["active"]
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
points = @client.send(:search, @client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
assert_nil points
+30 -6
View File
@@ -216,20 +216,44 @@ class PmtilesBuilderTest < Minitest::Test
)
end
def test_build_offline_also_writes_a_hiking_trails_geojson
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 and is valid (empty) GeoJSON — the real
# parsing logic is covered by test_hiking_trails.rb against real OPL
# fixtures.
path = File.join(@data_dir, "hiking_trails.geojson")
assert_path_exists path
# 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.
+5
View File
@@ -31,6 +31,11 @@
<div class="profile-panel__charts"></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 class="basemap-panel__header">
<strong>Base map</strong>