Commit Graph
79 Commits
Author SHA1 Message Date
randy cd56ebe0a1 Add in-app regeneration of offline.pmtiles and terrain.pmtiles
CI / check (push) Successful in 20s
CI / deploy (push) Successful in 1m52s
A logged-in user can now trigger, from a new "Offline data" sidebar
panel, what was previously a fully manual go-pmtiles workflow
(docs/development.adoc): regenerate either file for the current map
viewport, no area-picker (same "visible area is the area" convention
the Overpass POI plugin already uses).

- lib/maptool/pmtiles_builder.rb: two independent background jobs
  (Thread + Mutex-guarded status Hash, same shape as app.rb's own GNSS
  poller), each writing to a .tmp path and atomically renaming into
  place so a build never serves a partially-written file mid-run.
  offline.pmtiles is a single `pmtiles extract` against Protomaps'
  public daily basemap build (byte-range extraction, not a full
  ~120GB download). terrain.pmtiles has no equivalent public
  Terrarium-encoded archive to extract from, so it fetches individual
  Terrarium tiles itself, packs them into an intermediate MBTiles
  (new sqlite3 gem), then the same `pmtiles convert`.
- 4 new app.rb routes (POST .../build, GET .../build-status) plus
  size/mtime added to the existing availability endpoints.
- public/js/offline_data.js: new sidebar panel, polling build-status
  the same self-rescheduling-setTimeout shape livetrack.js already
  uses for its own refresh loop.
- Both network-heavy pieces (the Protomaps build index, per-tile
  fetches) are injectable, so test/test_pmtiles_builder.rb exercises
  the actual build pipeline against a fake `pmtiles` binary without
  hitting a real network, matching this suite's existing
  keep-it-offline convention for external services.
2026-09-11 01:25:08 +02:00
randy fba8ca10a2 Add contour lines to the Overlays panel via maplibre-contour
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 12s
5th Overlays item, generated client-side from the same online Terrarium
DEM tiles Terrain shading already fetches (no offline fallback — the
plugin's DemSource has no public hook to route through the pmtiles://
protocol the offline basemap uses, so this quietly renders nothing
without a network, same as Radar/the Wikipedia layer).

A plain vector source/layer under MapLibre's own tile lifecycle, no
manual refetch-on-move code needed. Thresholds are the plugin's own
generic starting point, not yet tuned against real terrain. Labels
skipped for now (font-name matching across the online/offline styles'
own glyph sets adds real risk for a nice-to-have, not the actual ask).
2026-09-11 00:12:32 +02:00
randy c8b6dd2fb1 Use the National Park typeface for the topbar wordmark
CI / check (push) Successful in 17s
CI / deploy (push) Successful in 12s
Self-hosted Bold woff2 only (the one weight .topbar__brand renders at)
under public/vendor/national-park-typeface/ — a router-carved-signage
display face for the "MAPTOOL" heading, matching the uppercase/
letter-spaced treatment it already had. Everything else stays on the
system font stack; this is a wordmark swap, not a body-text one.

docs/nps-resources.adoc/third-party-notices.adoc updated to record it.
2026-09-10 23:50:19 +02:00
randy 6e0b67f2a0 Support a persistent, private plugins directory alongside the built-in ones
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 13s
plugins/ ships baked into the image and gets wiped/replaced by the
checkout sync on every deploy (docs/deployment.adoc), so a private
plugin dropped there — the exact use case the plugin contract was
designed for — didn't survive a redeploy the way uploaded GPX/kiwix
data under data/ does.

Plugins.list/run now accept either one directory or an array, and
app.rb checks the built-in plugins/ plus data/plugins/ (already
covered by the existing ./data bind mount, no new volume needed).
2026-09-09 17:24:52 +02:00
randy 72ffd0ef18 Add an import plugin system, with the Overpass POI query as its first plugin
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s
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.
2026-09-09 16:23:31 +02:00
randy 5ff9b113d9 Proxy kiwix-serve through this app instead of linking to it directly
CI / check (push) Successful in 39s
CI / deploy (push) Successful in 12s
map.priesol.net sits behind a reverse proxy that only forwards to
maptool itself, so the previous approach (publish kiwix-serve's port,
link the browser straight at it) had no address a real browser could
ever reach — not just a Docker-internal hostname as first assumed, but
no host/port combination at all works from outside.

GET /kiwix/* now proxies to kiwix-serve (Kiwix.proxy in
lib/maptool/kiwix.rb, a plain Net::HTTP passthrough), and the kiwix
service runs with --urlRootLocation=/kiwix so every link/asset it
emits already carries that same prefix and needs no rewriting
(confirmed against libkiwix/internalServer.cpp, which strips this
exact prefix off incoming requests). Drops the port-publishing
approach from the previous commit entirely.
2026-09-09 15:29:08 +02:00
randy 9997a8a8c0 Fix note UX: unreachable wiki link, white Save hover, stale icon preview, invisible new points
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 12s
- "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.
2026-09-09 15:11:50 +02:00
randy 76e3239d54 Tidy the deploy checkout into build/, symlink docker-compose.yaml up
CI / check (push) Successful in 44s
CI / deploy (push) Canceled after 40s
Sources now sync into /srv/podman/maptool/build/ (wiped and fully
replaced every deploy, nothing in it needs preserving) instead of
directly into /srv/podman/maptool/ itself, where data/ and .env also
live — the old sync step had to specifically skip data/ by name to
avoid wiping it; this way there's nothing to skip.

docker-compose.yaml is symlinked (ln -sf, relative target) from
/srv/podman/maptool/ up to build/ — both for a human to still find it
at the top, and because that symlink's own location, not build/, is
what compose resolves ./data and .env's directory against. No changes
to docker-compose.yaml or the podman-compose@.service systemd unit's
WorkingDirectory needed.

Verified against a full local dry run of the new sync step (copy into
a fake build/, symlink up, confirm pre-existing data/ survives) and
`docker-compose config` against the real docker-compose.yaml through
that symlink — both volume mounts (./data, the kiwix ZIM path)
resolved to the sibling data/ directory as intended, not build/data.
2026-09-09 14:13:42 +02:00
randy c66ae8d2d0 Fix kiwix ZIM volume mount to work with a symlink
CI / check (push) Successful in 1m7s
CI / deploy (push) Successful in 14s
Bind-mounting data/kiwix/ (the directory) meant a symlink placed
inside it broke: the container has to resolve that symlink itself,
in its own filesystem, where the host path it points to doesn't
exist. Binding the exact data/kiwix/wikipedia.zim file path instead
resolves it on the host at mount time (plain mount(2) semantics), so
the real ZIM can live anywhere on the host without duplicating a
multi-GB file into this repo's data/ directory.
2026-09-09 14:01:39 +02:00
randy 24ac5931f8 Link POI popups to offline Wikipedia via kiwix-serve
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
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.
2026-09-09 13:44:37 +02:00
randy f53c74e06f Add rake overpass:pois to fetch camper POIs from Overpass
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
lib/maptool/overpass_pois.rb: queries Overpass for drinking water,
dump stations, LPG refill, toilets, laundromats, supermarkets/
convenience stores, and springs within a bbox, writes GeoJSON. A
dev-time tool (like the offline.pmtiles/terrain.pmtiles generation
steps already documented) — not called by the running app, drop the
output into data/gpx/ or upload it through the Tracks panel like any
other .geojson layer.

Verified against the real Overpass API (88 POIs for central
Bratislava) in addition to the new unit tests (pure to_geojson/
build_query logic, no network in the suite itself). Full suite: 103
runs, 0 failures.
2026-09-09 10:21:41 +02:00
randy 77a4c96b1c Add a hillshade/terrain-shading toggle to the map
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.
2026-09-09 09:27:17 +02:00
randy 5c9516d038 Check the deployed revision, not just any 200, in the health poll
CI / check (push) Successful in 53s
CI / deploy (push) Successful in 13s
/api/health now reports REVISION alongside status, and the CI wait
step checks for this deploy's own short SHA specifically - a stray
old container that never got torn down (or a build that silently
kept serving stale code) would still 200 on a plain health check but
now won't match.
2026-09-08 21:19:03 +02:00
randy 4982f04258 Verify the deploy actually came up, not just that it was launched
systemctl restart (Type=simple, no readiness protocol) returns as
soon as podman compose up --build forks, before the build or the
container start have actually finished - a build failure or crash
loop would still show as a green deploy job. Poll /api/health for up
to 5 minutes (comfortably above the ~3.5min worst-case cold rebuild)
and fail the job if it never answers.
2026-09-08 21:16:41 +02:00
randy a908cd7abc Fix the real cause of slow podman deploys: add --build, drop podman rmi
CI / check (push) Successful in 44s
CI / deploy (push) Successful in 3s
podman-compose@.service was missing the --build flag that
docker-compose@.service already has (added on cube.local:
/etc/systemd/system/podman-compose@.service), so removing
maptool:latest before every restart (my own earlier fix, meant to
force a rebuild) also destroyed the build cache along with it - every
deploy was redoing apt-get/bundle install from scratch (~3 min
measured, vs ~20s before). --build lets a plain systemctl restart
rebuild while still reusing unchanged layers, same as docker did, so
the ci.yml deploy step collapses back to one line.
2026-09-08 21:12:54 +02:00
randy e4764c64e4 Drive maptool's deploy through the podman-compose@ systemd service
The service's own ExecStart deliberately runs a plain podman compose
up (no --build) since ExecStartPre already tears down the previous
containers/networks/volumes on every start — but that leaves the
built image alone, so the deploy job now removes it directly first
(what forces a fresh build) and drives start/stop through systemctl
rather than calling podman/docker compose by hand. Also switches
/srv/docker/maptool to /srv/podman/maptool per the symlink now in
place, and updates deployment.adoc to match.
2026-09-08 20:33:03 +02:00
randy 67585cd99a Gate GNSS (turning it on and its position) behind login
CI / check (push) Successful in 48s
CI / deploy (push) Successful in 23s
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).
2026-09-08 14:06:29 +02:00
randy d9879bad60 Switch to GNSS immediately if it turns on mid-track
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 18s
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.
2026-09-08 08:50:43 +02:00
randy f0ac8ac5d4 Give the browser-location fallback its own (blue) marker color
CI / check (push) Successful in 50s
CI / deploy (push) Successful in 25s
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.
2026-09-08 08:04:31 +02:00
randy 6e5676c890 Make "find my location" actually follow GNSS, gated on the sidebar toggle
CI / check (push) Successful in 44s
CI / deploy (push) Successful in 18s
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.
2026-09-08 00:56:25 +02:00
randy aec665179f Prefer this app's own GNSS receiver for "find my location"
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 22s
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.
2026-09-08 00:46:14 +02:00
randy 3e81ee8015 Run the GNSS simulator in production too
Adds a gnss-simulator service to docker-compose.yaml, sharing
maptool's network namespace (network_mode: "service:maptool") so it's
reachable at the 127.0.0.1:2947 GpsdClient hardcodes -- there's no
real receiver attached to the server, so without this the "GNSS"
control there always shows "no fix". Both services now reference the
same image (maptool:latest) explicitly rather than each service with
its own `build: .` producing two separately-tagged (if
identically-layered) images.

Verified for real: built and ran the full compose stack in an
isolated project/port on cube (not the live one), confirmed
/api/gnss/position reports a moving fix across two polls through the
actual sidecar networking, then tore it down completely -- containers,
image, network, temp checkout. Production's own stack was untouched
throughout and confirmed still healthy afterward.
2026-09-08 00:33:44 +02:00
randy 61ba86c6e6 Add a GNSS simulator for dev without gpsd running
bin/gnss_simulator.rb speaks gpsd's own JSON wire protocol directly
on its default port -- GpsdClient connects to it exactly as it would
to real gpsd, no app changes needed, and no gpsd install/daemon
required either (unlike the existing fake-NMEA-source approach in
docs/development.adoc, which still needs gpsd itself in front of it).
Drives a loop around a center point at a driving-ish pace rather than
sitting still, since a fixed position isn't much of a "GNSS position"
to look at.

Verified end to end: started it, pointed a dev server at it, and
confirmed /api/gnss/position reports a moving fix across two polls.
2026-09-08 00:28:34 +02:00
randy ca224e95c1 Show the tree's file names without the .geojson suffix
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".
2026-09-08 00:11:41 +02:00
randy 1b4463a3c1 Consolidate original/GeoJSON download into one menu button
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.
2026-09-08 00:10:02 +02:00
randy bf17e00463 Hide write-action controls in the tree UI when logged out
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.
2026-09-07 23:31:45 +02:00
randy 058c0990b4 Document four more NPS-adjacent resources
- national-park-service/fonts: unofficial but genuinely open (SIL OFL
  1.1) typefaces inspired by public-lands signage -- unlike the
  proprietary Rawlinson/Frutiger this app already ruled out, this one
  has no licensing blocker if the look is ever wanted in this app's
  own UI chrome.
- The official NPS Data API (developer.nps.gov) -- a legitimate
  government data source, separate from the Mapbox-Atlas-licensed
  tile service. No current use case (this app isn't NPS-park-specific)
  but worth remembering.
- Two hobby projects (carlknutson/nps-changes-site,
  seankelliher/us-national-parks-alerts) noted as real-world examples
  of consuming that API, not as reusable libraries.
2026-09-07 23:21:37 +02:00
randy eca8519152 Require login to export files; allow downloading the original too
CI / check (push) Successful in 43s
CI / deploy (push) Successful in 16s
?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.
2026-09-07 23:18:59 +02:00
randy ceaaa0bb92 Apply the NPS-inspired palette to the online map too
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.
2026-09-07 23:12:20 +02:00
randy 24b6180c03 Fix overlapping Distance/Area text; label each segment's own length
CI / check (push) Successful in 45s
CI / deploy (push) Successful in 14s
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).
2026-09-07 23:05:54 +02:00
randy 12635a48c3 Badge the exception (not yet cached) instead of the near-universal case
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.
2026-09-07 22:59:02 +02:00
randy cd92dadf6a Let a cached file's GeoJSON be downloaded by clicking its tree name
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.
2026-09-07 22:56:46 +02:00
randy b4f205667b Support uploading .geojson files directly
CI / check (push) Successful in 45s
CI / deploy (push) Successful in 15s
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.
2026-09-07 22:48:58 +02:00
randy dd0ad6d845 Show waypoint photos from newer p4n2gpx exports
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.
2026-09-07 22:38:22 +02:00
randy dd272ffb8a Convert GPX/FIT to GeoJSON at upload time, not first view
GpxStore#save now calls geojson() itself right after writing the
file, so the (potentially tens-of-seconds, for a large POI export)
conversion cost is paid during the upload — covered by the frontend's
existing "Uploading…" status — instead of on whichever checkbox
toggle happens to be first, which otherwise reads as a silent hang
indistinguishable from broken. An unparseable file still saves fine;
the error just surfaces on first view same as before.
2026-09-07 22:33:29 +02:00
randy 96065b1237 Home button fits all of Slovakia; add an overview map, top-right
CI / check (push) Successful in 1m33s
CI / deploy (push) Successful in 27s
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.
2026-09-07 22:30:56 +02:00
randy ddf2ee4990 Cache parsed GPX/FIT GeoJSON to fix large POI-export loads
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 14s
A several-thousand-waypoint park4night area export re-parses the
whole file with REXML on every toggle — ~2.4s for a 5k-waypoint/3.6MB
sample, so tens of seconds for a 50k-waypoint one, likely the actual
cause behind a "Could not load" report on a 41.5MB export. MapLibre
already clusters the waypoint layer client-side, so the bottleneck was
never rendering — cache the GeoJSON conversion alongside the source
file (invalidated by mtime) instead of a bigger bbox/tiling rework.
2026-09-07 22:21:00 +02:00
randy 16d36e4441 Add a zoom-level readout to the map control stack
CI / check (push) Successful in 49s
CI / deploy (push) Successful in 18s
Matches the small numbered square NPS keeps next to its own zoom
buttons — a passive display, not a button, so it's plain markup with
no click behavior.
2026-09-07 22:12:06 +02:00
randy 88637ded22 Move the locate-me button into the top-left control stack
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 16s
GeolocateControl was alone at bottom-right while Home/zoom/base-map were
grouped top-left — split across two corners instead of one stack like
the National Park Service's own map controls. Moved it in, between zoom
and the base-map button.
2026-09-07 20:37:43 +02:00
randy 8fa3391c6b Add an Alt+click debug inspector, NPMap4-infobox style
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 13s
NPMap4 has infoboxControl: a generic "click anything, show every raw
property" debug view, kept separate from a dataset's own curated popup.
Not portable code (Leaflet), but the concept is: Alt+click anywhere now
opens a popup listing every rendered feature at that point via
map.queryRenderedFeatures — base map OSM tags included, not just this
app's own GPX/FIT/radar layers — with a raw key/value table per feature.

No dedicated UI for it on purpose: a keyboard-modified click needs no
button, toggle state, or cursor change of its own, and it's fine for it to
coexist with a layer's own click handler firing on the same click (e.g. a
GPX waypoint's normal popup) since this is a power-user/debugging
affordance, not a replacement for those. Feature properties can come from
an uploaded GPX/FIT file (untrusted input) via this app's own layers, so
every value is escaped the same way gpx.js's popups already are.
2026-09-07 20:34:15 +02:00
randy d58580f4d9 Document national-dataset-query (not adopted)
CI / check (push) Successful in 43s
CI / deploy (push) Successful in 14s
A CLI tool for pulling ArcGIS/ESRI REST data into GeoJSON/GeoPackage -
different category from the browser plugins/icons elsewhere in this doc.
No current use case (we have no ArcGIS data source to query) and,
unlike everything else surveyed so far, no LICENSE file at all.
2026-09-07 20:27:16 +02:00
randy ebc19cb46f Note that NPMap4 is currently more feature-complete than npmap5
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 20s
v4 is one mature, accumulated framework; npmap5 is a still-in-progress
rewrite split into small, independently-versioned (mostly pre-1.0)
plugins, several of which aren't merged yet. Worth knowing so we don't
assume v5 already has everything v4 does.
2026-09-07 20:24:38 +02:00
randy 215957382a Group Home/zoom/base-map into one top-left stack, NPS-style
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 16s
Home was alone at top-left and zoom (NavigationControl) was down at
bottom-right with GeolocateControl — split across two corners instead of
grouped together like the National Park Service's own map controls
(https://www.nps.gov/, screenshot: Home, zoom in/out, base map, all
stacked in one place).

Moved NavigationControl up to join Home (showCompass: false — this map
never rotates, so a bearing-reset button would be dead weight), and added
MapLayersButton, a small map-corner control that's a second entry point
into the same Online/Offline panel LayerToggleControl already manages in
the page's sidebar (both go through the same toggle() now, so there's one
source of truth for whether the panel's open). GeolocateControl stays
alone at bottom-right — it's a different action (go to my actual location)
from Home (reset to the app's default view), which was worth clarifying
since the two are easy to conflate.
2026-09-07 20:23:37 +02:00
randy a8f657b83c Document NPS typography (Rawlinson/Frutiger) and editorial style guide
CI / check (push) Successful in 44s
CI / deploy (push) Successful in 14s
Noted for completeness in docs/nps-resources.adoc — both proprietary/
licensed fonts, unlike the SIL OFL Noto Sans already vendored, so not
adoptable. The editorial style guide is NPS's own writing/terminology
conventions, not a design or code resource, included only since it was
shared alongside the typography notes.
2026-09-07 20:13:06 +02:00
randy bc505436c1 Document all NPS mapping resources investigated this session
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 16s
A reference doc (docs/nps-resources.adoc) covering everything looked at
while pulling icons/controls/theme colors from NPS's mapping ecosystem:
what's actually in use (symbol-library, home-button, the Park Tiles-inspired
offline palette), what was considered and deliberately skipped (svg-icons,
markdown-handlebars, the unmerged overview/carto/wms branches) and why, the
Park Tiles cartography research (Harpers Ferry Center lineage, the four
style variants, why the actual tile service is off-limits via Mapbox
Atlas licensing, the public style JSON and Mapbox Studio style IDs found),
and the still-open upstream PR revising artwork for three icons we use.

Worth having as a knowledge base rather than re-deriving any of this from
scratch next time — NPS's tooling has repeatedly turned out to be a better
source than building something from nothing.
2026-09-07 20:07:27 +02:00
randy a95964db77 Recolor the offline theme towards NPS Park Tiles' palette
CI / check (push) Successful in 44s
CI / deploy (push) Successful in 13s
Extended OFFLINE_THEME's color-role overrides to lean into the warm,
Harpers-Ferry-Center-derived look of the National Park Service's own "Park
Tiles" cartography, rather than the cooler default protomaps-themes-base
palette. A handful of values (background, the park/protected-area green, the
two road-importance tiers, the park-boundary line color) are the genuine
paint colors pulled from a public Park Tiles style JSON — the "Imagery"
variant is the only one of NPS's four styles served from a plain,
unauthenticated URL; "Standard"/"Slate"/"Light" resolve through Mapbox
Studio's private API with an access token, which isn't something to
piggyback on even though it's technically visible in their own JS bundle.
The rest of the palette (forest/scrub greens, water, sand, buildings) is
reasoned to match that same family rather than sourced.

Recreating the color choices is fine — that's not the kind of thing a data
license restricts. What stays off-limits is their actual tile service: its
OSM/terrain layers are licensed through Mapbox Atlas, a commercial,
non-sublicensable data service scoped to NPS's own use.
2026-09-07 20:00:50 +02:00
randy 170af80cac Add a Home button and fill icon gaps from NPS's newer symbol library
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 18s
Two things pulled in from github.com/nationalparkservice/npmap5-plugins:

- A "Home" button (public/vendor/npmap-home-button/, NPS's own MapLibre
  control) pinned to the app's CENTER/ZOOM, sitting top-left on the map. Now
  that the URL hash permalink can open the map at any view, "current view"
  and "the default view" aren't the same thing anymore, so a quick way back
  to the default is worth having.

- npmap5-plugins bundles a newer, actively-maintained version of the same
  NPS symbol-library already used for POI_ICONS (confirmed by its
  maintainer to be the designated replacement for the older standalone
  repo: nationalparkservice/npmap5-plugins#4), with a much larger icon
  vocabulary and a simpler CC0 (public domain) license. All 25 icons
  already in use came back byte-identical from the new source, so this
  just repoints their attribution — but it also has icons the old repo
  didn't: `forest` (POI_ICONS) is now the official "wilderness" pictogram
  instead of hand-drawn, and three of the five previously icon-less
  Services/Activities tags (wifi, mobile data coverage, winter/snow
  camping) now have real icons too. Only bakery and vehicle wash still
  have no NPS equivalent.
2026-09-07 19:45:19 +02:00
randy 481233c232 Hide the Base map switch when there's no offline map to switch to
CI / check (push) Successful in 46s
CI / deploy (push) Successful in 19s
The Online/Offline sidebar icon was always shown, even on an install with
no data/offline.pmtiles — offering a choice that can't actually work.
LayerToggleControl#remove() removes the icon (and hides its panel) once the
existing /api/offline-tiles check confirms there's nothing to switch to; a
transient failure of that same-origin check leaves the switch as is rather
than guessing, since that's the request itself failing, not evidence
offline tiles are unavailable.
2026-09-07 17:32:05 +02:00
randy fd8852c0e1 Fix meta-row color regression, show Services/Activities as icon tags
CI / check (push) Successful in 43s
CI / deploy (push) Successful in 15s
The gray color from the previous commit landed on .gpx-popup__meta (the
whole row, label and value both) instead of just .gpx-popup__meta-label —
only the label was supposed to be muted, the value should read as normal
text same as the description above it.

While in there: Services/Activities were showing park4night's own raw,
comma-separated codes ("animaux, point_eau, wc_public"), which don't mean
anything to a reader on their own. PARK4NIGHT_TAGS (icons.js) maps the
codes p4n2gpx's exports actually use to a human label and, where the
National Park Service's icon set (already used for POI_ICONS) has a
reasonable match, a small icon — formatTags (gpx.js) renders each as an
icon+label tag instead of raw text. A code not in that table just falls
back to its own raw text, so nothing is ever hidden.
2026-09-07 17:25:37 +02:00
randy 152a08f922 Sync the map view to the URL, like Google Maps/OSM permalinks
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 19s
Panning/zooming now updates #map=<zoom>/<lat>/<lon> in the URL, and loading
a URL with one already opens at that view instead of the default center —
so copying the URL bar shares the exact spot someone's looking at, and a
page reload doesn't lose the current view either.

This is MapLibre GL JS's own built-in Hash control (hash: "map" on the Map
constructor) — it already does exactly this, replaceState and all, no
custom code needed.
2026-09-07 17:12:21 +02:00