Both used to be their own separate sidebar icons; neither has
anything to manage beyond on/off, same as Wikipedia already got its
own Overlays entry for. Terrain shading is a plain checkbox item now.
Radar keeps its scrub/forecast controls, but behind an explicit
expand (clicking its name), separate from the checkbox — checking it
on shouldn't force the details open, and expanding to scrub/check the
forecast shouldn't also flip the layer on.
Both controls now build their own <li> (RadarControl.mount(li),
HillshadeControl.mount(li) — the latter moved into overlays.js
entirely, it has no reason to live in map.js once it's not building
its own sidebar icon) rather than a whole sidebar-item + flyout the
way they used to.
Also gave each Overlays item a leading icon: Wikipedia and Terrain
shading reuse existing NPS pictograms (WIKI_ICON, and WILDERNESS_ICON
newly exported from icons.js's forest entry — NPS has no dedicated
mountain/terrain glyph, wilderness-22.svg is the closest honest
match). Radar's is hand-drawn (a rain cloud) since NPS's set has
nothing weather-related to reuse.
The topbar's coordinate display was plain decimal degrees ("48.14611,
17.17722") — switched to DMS (48°08'46"N 17°10'38"E), the more
GPS-unit-like format Locus Pro itself uses, and made it click-to-copy
(plain decimal, since that's what pastes cleanly into another map/GPS
app — not the DMS text actually shown).
Added sunrise/sunset + a countdown to whichever's next, for the same
map-center point the coordinates already describe. Vendored SunCalc
(BSD-2-Clause, github.com/mourner/suncalc) rather than hand-rolling
the astronomical math — small, dependency-free, and correctness here
has real pitfalls (equation of time, refraction, polar day/night) a
first-pass reimplementation would risk getting subtly wrong.
Also added a test asserting every public/js/*.js module has a
versioned importmap entry in layout.erb — caught that overlays.js
(from the previous commit) was missing one, which would have quietly
skipped ASSET_VERSION's cache-busting for that file after every
deploy.
Wikipedia doesn't belong inside "Tracks & Points" — that panel is for
files you add/remove/reorder, while Wikipedia is a live layer with
nothing to manage, just on/off. Gave it its own sidebar panel
(#overlays-panel, same shell as LiveTrackPanelControl) instead, sized
to hold more such checklist-only layers later without a redesign.
Moved all the wiki layer logic out of GpxPanelControl into a new
OverlaysControl (public/js/overlays.js). svgToImageData/badgeImageId/
escapeHtml are now exported from gpx.js so it doesn't have to
reimplement them — the same three helpers a second overlay would also
need.
The old link only ever surfaced one nearby article at a time, buried
in whatever POI's popup you happened to open, and only for uploaded
files with a category. Replaced it with a standalone "Wikipedia"
toggle under the renamed "Tracks & Points" panel: enabling it adds a
live map layer that refetches GET /api/kiwix/nearby on every pan/zoom
and plots every nearby article as its own point.
kiwix-serve's own geo search never echoes back a result's coordinates
(confirmed live against the real production ZIM, both its HTML and
?format=xml output) — it only filters by distance from a point. So
Maptool::Kiwix.nearby fetches each matching article's own page too and
reads its <meta name="geo.position"> tag, which every geotagged
Wikipedia article carries. Tried parallelizing those per-article
fetches with threads first; measured no improvement against the real
ZIM (kiwix-serve appears to serialize requests regardless), so it
stayed sequential — ~1.6s for a 30-result page, fine for a
refetch-on-pan-end layer.
Clicking a point opens a small popup linking to the article (proxied
through the existing /kiwix/* passthrough). New WIKI_ICON (NPS's
bookstore-22.svg) marks these, kept out of POI_ICONS since that set
doubles as notes.js's icon-picker option list and this fixed-glyph
layer has no per-feature category to key off of.
- notes.js's ICON_LABELS wasn't extended for the shop_supermarket/
shop_convenience keys icons.js added — the note icon picker showed
the raw internal key as the title instead of a real label.
- Plugins.run mislabeled a malformed plugin.json as "plugin did not
print valid JSON" — a message written for bad script stdout, not a
bad manifest, since both paths shared one rescue.
- overpass_pois.rb's description_for joined raw OSM tag values with
"\n" — an operator/opening_hours tag with an embedded newline could
inject an extra line into the popup that gpx.js's formatDescription
(built for p4n2gpx's own well-formed lines) would render as its own
independent, legitimate-looking link. Tag values are stripped of
embedded newlines before joining.
TODO.adoc had this flagged: toggling a category off in the legend only
re-sliced the points source (_applyCategoryFilter), so a polygon or
line note (Measure's "Save as note") sharing that category stayed
visible. Added _linesByPath alongside the existing _pointsByPath,
tracked/cleaned up the same way, and reused for both the legend's
category scan and the filter's re-slice.
A failed run showed "Failed — see server log" regardless of why —
misleading, since a Plugins::RunError never actually reaches any log
(app.rb turns it straight into a 502 JSON response). Found this the
hard way debugging the p4n2gpx plugin, having to SSH in and reproduce
the run by hand each time just to see why it failed. Now shows the
response's actual error message (e.g. "plugin exited with status 1"),
wrapping instead of overflowing the flyout for a longer one.
Overpass categories now carry the same icon+color styling a p4n2gpx
waypoint gets, instead of falling back to gpx.js's plain red marker,
plus a popup description built from opening_hours/operator/website
tags. Supermarket/convenience store reuse NPS's generic store glyph,
the only honest match in that icon set.
Each plugin run's saved filename now bakes in the area's center and a
timestamp, so repeated downloads for different areas land as separate,
identifiable files instead of overpass-pois.geojson/-1/-2/...
A plugin is a directory under plugins/ with a plugin.json manifest
({"label": "...", "command": "./run.sh"}) plus a script that reads
--south/--west/--north/--east and prints a GeoJSON FeatureCollection
to stdout (lib/maptool/plugins.rb) — that's the entire interface a
plugin needs to know about this app. The sidebar's new import icon
(logged in only, GET /api/plugins + POST /api/plugins/:name/run) lists
whatever's found and runs one for the current map viewport, saving its
output as a new .geojson file the same way an upload would be.
The point: a scraper for a site whose ToS forbids that (Park4Night,
iOverlander, Freecampsites) doesn't need to live in this public repo
to work here — its own directory just goes under plugins/ locally and
is never git add'ed, no special-casing needed anywhere.
plugins/overpass-pois wraps the existing rake overpass:pois query as
the first, in-repo, reference example of the contract.
Also fills in four modules (auth.js, locate.js, measure.js, notes.js)
missing from layout.erb's cache-busting importmap alongside adding
plugins.js's own entry — a pre-existing gap that meant they could keep
serving a stale cached copy across a deploy despite the importmap
existing specifically to prevent that.
- "Nearby on Wikipedia" linked to KIWIX_URL, a Docker-internal hostname
(http://kiwix:8080) no real browser can resolve. Split it: KIWIX_URL
stays server-side-only for the reachability check, and the link
itself is built from KIWIX_PUBLIC_PORT (kiwix-serve's newly
published host port) plus whatever host the visitor already used to
reach maptool — works regardless of which hostname/IP that is.
- The Save button went white on hover in GTK-themed Chrome: the
browser's native button skin was painting over the custom
background because appearance wasn't reset.
- The map preview marker for a pending note always showed a plain red
pin no matter which icon was picked in the form. NoteSaveForm now
reports icon changes via onIconChange so NotesControl can update the
marker's glyph live.
- Saving a note (or a Measure "Save as note") left the new/grown
.geojson file unchecked and invisible until manually toggled on.
notifyGpxStoreChanged now carries the affected path so
GpxPanelControl auto-shows it.
lib/maptool/kiwix.rb: a small reachability check for an external
kiwix-serve instance. GET /api/kiwix reports it (same optional-
integration pattern as offline-tiles/terrain-tiles); the frontend
adds a "Nearby on Wikipedia" link to a point's popup pointing straight
at kiwix-serve's own geo search (GET /search?latitude=&longitude=
&distance=, confirmed from libkiwix's actual source rather than
guessed) — linked to directly, not proxied/embedded, since it already
has a full search UI of its own.
docker-compose.yaml: new `kiwix` service (ghcr.io/kiwix/kiwix-serve)
on the default network, KIWIX_URL wired to reach it by service name.
The ZIM itself isn't part of the repo (large, self-contained archive)
— data/kiwix/ gitignored same as the *.pmtiles files, setup steps in
docs/development.adoc.
No kiwix-serve/ZIM available here to test end-to-end — verified the
app side against a stand-in local HTTP server (GET /api/kiwix flips
available correctly) and confirmed the generated popup link matches
the real geo-search query shape. docker-compose.yaml validated with
`docker-compose config`. Full test suite: 118 runs, 0 failures.
- The clicked location now stays marked (a plain maplibre-gl Marker,
removed on save/cancel/deactivate) while the note form is open —
before, nothing on the map showed where the pending note would land.
- POI_ICONS gains a dozen more symbols (water, dump station, toilets,
playground, viewpoint, swimming, fishing, climbing, hiking, …) via
Object.assign after PARK4NIGHT_TAGS' own icon constants are declared
(they're below POI_ICONS in the file, so the literal itself can't
reference them directly) — reused as real marker-badge glyphs, not
just PARK4NIGHT_TAGS' inline description-tag icons. NoteSaveForm
gets a picker grid for these, shown only for a Point note (a
Measure-drawn line/polygon has no one spot for a badge).
- Fixed: .notes-panel__save's blue background was losing to
.notes-panel__actions button's gray — same class-vs-class+element
specificity trap .gpx-panel__action[hidden] already has a comment
about elsewhere in this file. The button was never actually blue.
Verified live (properly simulating a logged-in session this time, via
Page.addScriptToEvaluateOnNewDocument rather than a post-load DOM
poke, since NotesControl's mount decision happens once, synchronously,
at page load): marker appears and clears correctly, 19 icons + "none"
render and select, Save renders #1a5fb4. Full suite: 115 runs, 0
failures.
Both NoteSaveForm (create) and the popup's inline Edit form now have
a title input (-> properties.name, shown in the black header, same as
every other waypoint popup) and a separate body textarea (->
properties.description) for more detail than a single line.
New formatNoteBody renders that body with a small self-rolled
markdown subset — **bold**, *italic*, auto-linked URLs (reusing the
existing linkify), newlines to <br> — deliberately a new function, not
a change to formatDescription: that one renders uploaded GPX/park4night
content, which is untrusted, and docs/nps-resources.adoc already has a
specific call not to add markdown rendering to that path. A note's
body is written by the logged-in viewer themselves, not uploaded, so
that concern doesn't apply here — the escape-then-selectively-markup
order (linkify first, since it does its own escaping, then wrap
already-escaped text in real <strong>/<em> tags) keeps it safe either
way.
Verified live: title renders in the popup header, **bold**/*italic*/
a URL in the body all render correctly. Full test suite: 115 runs, 0
failures (unaffected — description is just another property, not
something the backend routes inspect).
The earliest notes (saved before the previous commit added id
tracking) have no properties.id — update/delete target a feature by
that id, so offering Edit/Delete for one without it just 404s
("Could not delete/save the note", the generic fetch-failure alert).
The popup no longer offers Edit/Delete without an id. New
rake gpx:backfill_note_ids assigns one to any pre-existing Note
feature missing it, so already-saved notes become editable again too
— run once against wherever data/gpx actually lives (e.g. inside the
deployed container).
Drag: mousedown on a vertex circle calls event.preventDefault() (the
documented way to opt a gesture out of MapLibre's own drag-to-pan),
then a map-wide mousemove/mouseup follows the cursor and updates that
point's coordinates live. Delete: clicking a vertex selects it (shown
larger/yellow); Delete or Backspace removes it, guarded against
eating a keystroke meant for an actual text input on the page.
A vertex's own mousedown sets a one-shot flag so the same click
doesn't also fall through to the generic map-click handler and add a
brand new point on top of it.
Adding a note (either tool) always ended in a 401 for a logged-out
viewer — the write-only affordance just wasn't hidden yet, unlike
GpxPanelControl's own upload/new-folder buttons, which already are.
NotesControl isn't added to the map at all when logged out (its whole
purpose is writing); MeasureControl's "Save as note" button is hidden
but the tool itself stays usable for plain measuring either way.
Measure already draws distance lines and area polygons by hand — a
second click-to-draw UI in the notes tool (Point/Area mode, from the
previous commit) duplicated that. Replaced it with a "Save as note"
button on the Measure panel that saves whatever shape is currently
drawn there; NotesControl goes back to being just a one-click point
pin. Both share a single NoteSaveForm (notes.js) for the note-text +
target-layer-file UI instead of two copies of it.
gpx.js: the "lines" source now also renders a note-flagged LineString
(dashed, popup-on-click) alongside the Polygon fill/outline from
before — the plain track-line layer excludes category:"Note" features
so a saved distance note doesn't also get the track hover-summary/
profile-panel treatment.
Notes are now individually editable/deletable from their own popup
(logged-in only): Edit swaps the popup content for an inline
textarea, Delete confirms and removes. Both target the feature by a
new client-generated properties.id rather than array position (new
GpxStore#update_feature/#delete_feature, PUT/DELETE /api/gpx/notes),
since neither array index nor the note's own text (the thing being
edited) is a stable identifier.
Verified live: Measure's Save button (disabled/enabled state, saves
the exact on-screen shape), the popup's Edit/Delete buttons and inline
edit form. Full test suite: 115 runs, 0 failures (new coverage for
update_feature/delete_feature and the PUT/DELETE routes).
notes.js: Point/Area mode toggle mirroring measure.js's UX — Area
mode accumulates clicked vertices with a live dashed-preview polygon
(own small preview source/layers), a Finish button once 3+ corners
are placed, then the same save form as Point mode.
gpx.js's _addLayers now also renders Polygon features from a file's
"lines" source (fill + outline, filtered by geometry-type — the
existing line layer already only draws LineString, so this is just
two more layers on the same source, not a new bucket) with
popup-on-click like a waypoint, not the track line's hover-summary/
profile-panel behavior. Without this a saved polygon wouldn't have
rendered back on reload.
Fixed along the way: .notes-panel__form/.notes-panel__modes both set
their own `display`, which silently wins over the browser's default
`[hidden] { display: none }` (author styles always beat user-agent
ones) — same gotcha .gpx-panel__action[hidden] already has a comment
about. The form was never actually invisible before being opened.
Verified live: real polygon file (fill/outline render, click ->
popup) and the full Area-mode draw flow (mode switch, live preview,
Finish, form) via headless Chrome. Full test suite: 104 runs, 0
failures — one new backend test confirms a Polygon feature round-
trips through POST /api/gpx/notes.
Known gap (noted in TODO.adoc): the category filter matrix only
re-slices the points source, so toggling "Note" off hides point notes
but not polygon ones.
New map-corner "Add note" control (public/js/notes.js), mirroring
measure.js's click-to-place UX. Click the map, write a short note,
pick an existing .geojson layer file or name a new one — saved via a
new GpxStore#append_feature (grows the target file in place, unlike
save()'s always-disambiguate-the-filename behavior) through a new
POST /api/gpx/notes route.
No new rendering path: a note is just another point in a .geojson
file the Tracks panel's tree already lists, checkboxes, and pops up —
visibility toggling ("layers") comes for free from that existing
machinery, per-file, with no changes needed there.
Points only for now — a polygon mode would need gpx.js's point/line
split taught a third Polygon bucket first, or a saved polygon
wouldn't render back on reload (see TODO.adoc).
Covered by new tests: GpxStore#append_feature (create/append/missing-
path/filename-safety) and the /api/gpx/notes route (auth gate, create,
append, malformed/missing-geometry rejection, 404 on a missing
target). Full suite: 97 runs, 0 failures.
public/sw.js: network-falling-back-to-cache for RainViewer radar tile
requests specifically (nothing else — the app itself is served by the
same local server that's already the offline resource in the van, so
there's no app-shell to precache). Registered from layout.erb, a no-op
where it can't register (insecure context) rather than an error.
Verified: registers and controls the page, tiles get cached on a
normal fetch, cache.match() correctly retrieves them by the same
request. CDP's network-blocking simulation doesn't interact cleanly
with Service Worker fetch interception (a known tooling quirk, not
specific to this), so the actual offline fallback path is the
standard try/catch pattern, verified by code review rather than a
simulated-offline screenshot.
Radar flyout now also shows a 3-day Open-Meteo forecast (free, no API
key) for the map's current center. Radar frames and the forecast fetch
independently — one being unreachable doesn't hide the other. The last
successful forecast is cached in localStorage and shown (with a
"cached from" note) when the live fetch fails, so there's still
something useful once actually offline.
Radar tile images themselves aren't cached — that needs a Service
Worker, left as a separate follow-up (see TODO.adoc).
Reuses the same raster-dem source the hillshade toggle already added
— a color-relief layer tints by elevation band (green valleys through
brown mid-slopes to pale grey peaks, in this app's own palette) under
the hillshade layer, so one toggle gives the combined shaded-relief
look instead of two separate layers.
Sidebar toggle (new HillshadeControl) adds a raster-dem hillshade
layer: public Terrarium tiles when online, or a local
data/terrain.pmtiles when present (mirrors offline.pmtiles' optional-
local-file pattern, new /api/terrain-tiles + /tiles/terrain.pmtiles
endpoints). No offline terrain data is bundled, so the toggle is
online-only until one is generated — see docs/development.adoc for
how.
Offline: fill in protomaps' "other" road-kind color (unset before,
so tracks/paths silently fell back to plain white against the cream
background); also fixes a dead "minor" theme key that was never read
by the actual paint expression (only minor_a/minor_b is), so ordinary
streets were quietly using the base theme's default yellow instead of
this app's cream.
Online: Liberty's own road_service_track layer merges paved service
roads with unpaved tracks into one color, which isn't safe to just
recolor brown (real driveways aren't unpaved). Clones it into a
second layer scoped to class=="track" only, dashed and dirt-brown,
matching the offline treatment without mislabeling paved service
roads.
Small green/gray indicator reusing the existing /api/offline-tiles
check, showing at a glance whether this server has offline map data.
Closes out the top banner TODO item — DEM elevation and a hamburger
menu are intentionally left out (no DEM layer yet, and the sidebar
isn't mobile-collapsed).
Legend items are now toggle buttons (icon grid, not a color-swatch
list) that hide/show that category's waypoints on the map. Filtering
re-slices each file's source data rather than using a layer filter,
since MapLibre clusters a GeoJSON source's full data regardless of
what a layer filter hides.
Black topbar banner with live map-center coordinates, a black header
(with NPS pictogram) on waypoint popups, offline basemap wood/park
colors unified with the online Liberty green, and the sidebar grouped
into the planned Explorer / Offline hub / Navigation menu sections.
Ended sessions past the ~24h Garmin appears to keep a live-track page
around for get verified once at app startup and, once confirmed dead,
flagged so the panel greys them out instead of only failing after a
click. Already-flagged sessions are skipped on later startups so this
doesn't keep re-hitting Garmin for the same dead session.
GET /api/gnss/position now requires login (app.rb) -- it reveals
where the receiver physically is, not something to leave open to
anyone with the URL. GnssControl's sidebar icon is hidden entirely
when logged out (same pattern as the tree UI's write controls) rather
than shown and failing to ever get a fix.
LocateControl needs no changes: it only ever reaches GNSS through
GnssControl#isEnabled(), which can't turn true for a logged-out
visitor if the toggle is never on the page to click. Its
browser-geolocation fallback stays open to everyone -- a visitor's
own device asking for their own location isn't something to gate.
Extracted isLoggedIn() out of gpx.js into a shared public/js/auth.js
(gnss.js needed the same check) rather than duplicating it.
Verified: /api/gnss/position returns 401 logged out; the sidebar icon
is actually hidden (computed display: none, not just present-but-
unstyled -- checked properly this time after almost repeating the
same present-vs-visible test mistake the .gpx-panel__action bug
already taught).
If Locate was already tracking via the browser-geolocation fallback
(GNSS was off when clicked) and GNSS then gets turned on via the
sidebar, it kept using the browser position until the button was
clicked again -- the browser's own (often less accurate) position had
no business still being used once the app's own receiver had a fix.
GnssControl#onEnabledChange (a small internal callback list) notifies
LocateControl the moment the sidebar toggle changes; if it's actively
tracking via the browser at that point, it stops that and jumps to
GNSS's position instead.
Caught a real bug while verifying this: the automatic handoff reused
the same jump-to-GNSS helper a deliberate click uses, which alerts if
GNSS has no fix yet -- but GNSS only just turned on and almost
certainly hasn't gotten its first poll response back yet at the exact
moment the handoff fires, so this popped a blocking window.alert()
(which halts the whole page until dismissed) on every single enable.
A test hung on exactly this before I noticed. Fixed with an
alertIfNoFix option, true only for the deliberate-click path.
Verified end to end (simulating the "already tracking" state directly
since headless geolocation isn't reliable enough to trust as the
trigger): toggling GNSS mid-track removes the blue browser marker,
shows the green GNSS one once its poll lands, and no dialog appears.
The browser-geolocation fallback was reusing GnssControl's green
.gnss-marker for its own position dot -- on a machine with no real GPS
chip, wifi/IP-estimated location can jump around noticeably between
watchPosition updates, and a green dot doing that reads as "GNSS is
somehow still being used" even when it very much isn't (confirmed:
code review found no path from locate.js to /api/gnss/position at
all, and GNSS was off in the report). Switched to MapLibre's own blue
pulsing .maplibregl-user-location-dot -- the same look GeolocateControl
itself had here before, and now visually distinct from GNSS's green
on purpose rather than by accident.
Verified: both markers rendered side by side (GNSS on via the
simulator, plus a manually-placed browser-fallback-style marker) --
clearly different colors, no shared styling.
Two fixes based on feedback after the last commit:
1. The map wasn't following the position at all -- only the first fix
recentered, later updates just moved the marker. Now every fix
recenters (easeTo, not flyTo, after the first one, so it doesn't
repeat that zoom swoop on every update) -- matches how
GeolocateControl's own trackUserLocation always behaved.
2. GNSS should only be used when explicitly turned on via the sidebar
"GNSS" icon, not whenever a receiver happens to have a fix --
simpler to test against the simulator that way, and puts the
visitor in control of whether GNSS is engaged at all. GnssControl
now owns following the map on its own (same flyTo-once/easeTo-after
pattern) whenever it's enabled; LocateControl checks
GnssControl#isEnabled() and, when true, just jumps to
#getPosition()'s current fix and reuses its existing .gnss-marker
instead of polling/marking on its own. Only falls back to the
browser's own geolocation when GNSS is off.
Verified end to end with the GNSS simulator: enabling sidebar GNSS
recenters immediately and keeps following over several polls;
clicking Locate while GNSS is on jumps to the same position without
creating a second marker or touching its own active state; turning
GNSS back off and clicking Locate correctly falls through to the
browser geolocation path instead.
LocateControl (public/js/locate.js) replaces MapLibre's own
GeolocateControl -- same button, reusing its exact CSS/icon so it's a
visual drop-in, but sourced differently. Checks GET /api/gnss/position
once on click: if a fix is available (gpsd, possibly
bin/gnss_simulator.rb's simulated one), tracks that instead of the
browser's separate (and often less accurate) location service, which
is now only a fallback. Only the first fix recenters the map; later
updates just move the marker, unlike GeolocateControl's own default
of recentering on every update, which would fight a pan/zoom made in
the meantime.
Verified end to end with the GNSS simulator running: clicking the
button centered on the simulated position and dropped a marker,
clicking again cleared both, no console errors.
Mixing the legacy "$type" filter with an expression (["get", "role"])
in the same filter logs as unsupported in newer MapLibre. Modernized
all three measure layer filters to expression syntax; the vertex
circle layer's filter simplifies to just checking "role" (it's only
ever set on Point features to begin with, so the geometry-type check
was redundant once "role" was added).
Found while trying to reproduce a report that per-segment distance
labels don't show on a longer, several-point measurement -- couldn't
reproduce it locally or against production itself (both showed labels
correctly on a fresh page load), so this may be unrelated; still
worth fixing on its own.
.gpx-panel__action's own "display: flex" outranks the browser's
default "[hidden] { display: none }" -- same specificity, but an
author stylesheet rule always beats a user-agent one -- so setting
.hidden = true on these (GpxPanelControl#mount) never had any visual
effect. The per-file row icons (delete/download) were unaffected by
this since they're conditionally never added to the DOM at all rather
than hidden after the fact, a different mechanism that doesn't hit
this bug.
Same class of bug as the earlier Distance/Area overlap fix. Swept the
rest of the app's [hidden]-manipulated elements for the same pattern
(a display override with no [hidden] counterpart) -- this was the
only other one.
Verified in a real browser: computed display is now "none", not just
the hidden attribute being set with no visual effect.
The suffix ("ride.gpx" -> "ride.gpx.geojson" once cached) explained
what the name would download back when it doubled as a GeoJSON-only
download link. Now that downloading is the download button's job
(previous commit), it was just confusing -- the file itself is still
named "ride.gpx".
The file name doubling as a GeoJSON-only download link was easy to
miss as a link at all, and only ever covered half of what's actually
downloadable. One button next to delete now opens a small menu
(Original file / GeoJSON) instead -- costs a click when downloading
several files in a row, but both formats are reachable from one,
discoverable place. Only one menu open at a time, closed on any
outside click.
Verified in a real browser: opens with both items, closes on outside
click, no console errors.
fetch() has no cross-browser upload progress event, so the upload
path now uses XMLHttpRequest instead -- its upload.progress firing
with loaded === total is the only client-observable signal that the
server has moved from receiving bytes to converting them, worth
surfacing now that a large POI export's conversion (GpxStore#save)
can itself take several seconds on top of the transfer. Multi-file
uploads now also show which file (i of N) is in flight, previously
just a static "Uploading N files..." for the whole batch.
Verified the 401 path still redirects to /login through the new XHR
flow the same way fetch/redirectIfUnauthorized did.
Delete (file and folder), download, upload, and new-folder were all
rendered unconditionally and relied on the backend's 401 + a redirect
to /login on click -- fine for surfacing the reason something failed,
but showing controls that plainly won't do anything until you log in
was the actual complaint. isLoggedIn() checks for the topbar's own
"Log out" link (only rendered when logged_in) instead of threading a
new flag through -- already there, and login state can't change
without a full page reload anyway.
Caught a real gap while verifying in a real browser: the folder row's
own delete button wasn't gated (only the file row's was), so a
"logged out" check still showed a delete icon on every folder. Fixed
before it shipped -- confirmed clean in both states afterward.
?download=... on GET /api/gpx/file now requires login, same as
upload/move/delete/create-folder -- plain viewing (no ?download)
stays public. Added ?download=original alongside the existing
GeoJSON export, serving the untouched, as-uploaded file directly via
send_file -- the file name link still downloads GeoJSON, a new
button next to delete downloads the original.
Since both are real <a href> links (so right-click/middle-click still
work), a logged-out click can't just be left as a plain navigation --
it would land on a raw JSON error page instead of being sent to log
in. _download (gpx.js) fetches instead, reuses the existing
redirectIfUnauthorized 401 check, and only then turns the response
into a blob and clicks a throwaway <a download> to trigger the save.
Verified in a real browser: an anonymous click on either download
correctly redirects through /login to the real OAuth login page
instead of showing raw JSON.
ONLINE_STYLE_PATCH mirrors OFFLINE_THEME's color choices onto
OpenFreeMap Liberty's OpenMapTiles layer ids (a separate hand-built
table -- the two schemas share no layer names). loadOnlineStyle
fetches Liberty's style.json once, patches it in place, and the
result replaces the plain URL everywhere ONLINE_STYLE is used --
MapLibre accepts a style object anywhere it accepts a style URL, so
applyStyle needed no change.
Runs before the map (and the rest of the page) is constructed, so
there's no flash of Liberty's own colors first -- but with a 3s
timeout and a catch-all fallback to the plain URL, since this app is
meant to keep working offline in the field and blocking startup on a
remote style server would undermine that.
Verified in a real browser: online map now visually matches the
offline theme (cream ground, brown/khaki land cover, green parks,
muted roads), no console errors, Online/Offline toggle still works.
The measure panel's mode buttons sit inside a maplibregl-ctrl-group
container (for the ruler button's own 29x29 sizing) — its global
"button" rule reaches them too, clipping "Distance"/"Area" into a
fixed 29px square with zero padding instead of sizing to fit the
text. Fixed by upping specificity (".measure-panel .measure-panel__mode")
rather than fighting the cascade with !important.
In distance mode, each segment between two clicked points now shows
its own length at its midpoint, not just the running total in the
panel — a symbol layer keyed off the existing source, filtered apart
from the vertex circles by a new "role" property.
Verified both in a real browser (panel no longer overlaps; two
638m segments rendered and labeled correctly summing to the panel's
1.28km total).
GpxStore#save converts eagerly, so "cached" is true for nearly every
file in the tree already — badging that was just noise. Flipped to
PENDING_ICON, shown only for the rare case: a .gpx/.fit dropped
straight into data/gpx/ on the filesystem, bypassing the app, and not
yet opened.
Also add an explicit download button next to delete (DOWNLOAD_ICON) —
the file name itself has been a download link since the last commit,
but isn't obviously clickable at a glance.
Once a .gpx/.fit is cached, the tree shows it under its converted name
("ride.gpx" -> "ride.gpx.geojson", matching the real cache file next
to it on disk) instead of still reading as un-converted -- the
GeoJSON is what's actually on the map from that point on, and the
original is never deleted, just not re-shown as itself. The name is
now a real link (GET /api/gpx/file/<path>?download=1) instead of
plain text, the only way to get that converted form out of the app.
Verified in a real browser that an <a> nested in a <label> claims its
own click rather than the label forwarding it to the checkbox, so
downloading and toggling visibility don't fight over the same click.
A .geojson file needs no GPX/FIT conversion — it's already the shape
gpx.js expects — so GpxStore#geojson now just reads and returns it
as-is, no cache sidecar. save/tree/the upload dropzone and its zip
handling all pick it up through the existing TRACK_EXTENSIONS list.
Also fixes a bug the new "cached" tree icon would have shipped with:
a GPX/FIT's own .geojson cache sidecar (e.g. "ride.gpx.geojson") also
matches the new .geojson branch in list_children, which would have
listed it as a second, phantom tree entry — excluded explicitly, with
a regression test.
GpxStore#tree now reports a "cached" flag per file (reusing the same
mtime freshness check geojson() already does); the file tree shows a
small curly-braces icon next to any file whose conversion is already
on disk, so it's visible which files will toggle on instantly versus
which still need to parse.
A newer p4n2gpx run (Ruby rewrite, ~/test/mapping/p4n2gpx) started
emitting one plain GPX <link href> per park4night listing photo,
which this app parsed but never displayed. Gpx.waypoint_photos
collects them into a photos property; the waypoint popup renders them
as a row of thumbnails above the description, each opening full-size
in a new tab. Absent on older exports and plain GPS waypoints, so the
row just doesn't render for those.
Home now fitBounds()s a country-wide bbox instead of resetting to
CENTER/ZOOM's Bratislava point, since Home resetting into the same
city you'd already be looking at wasn't useful.
The overview map is NPS's own overview plugin (npmap5-plugins, still
on an unmerged branch), vendored and converted from its UMD dist
build to a plain ES module. Patched one MapLibre API break found while
verifying it: the plugin reads map.transform (public in the MapLibre
~2.4 it targets) to relax the inset's polar pan limit; MapLibre 6 (this
app's vendored version) no longer exposes .transform at all, so it
threw on load. Guarded to skip that cosmetic adjustment when absent
instead.
A ruler button in the top-left control stack, matching NPS's own
Area/Distance ruler. Click to add vertices; distance sums haversine
segment lengths, area uses the spherical-excess polygon formula
(same one turf.js's area() uses) rather than a flat shoelace, since a
flat approximation drifts at park-boundary scale. SI units only
(m/km, m²/km²) — no imperial toggle.