- 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.
?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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
The previous pass applied Park Tiles' real park/protected-area green
(#b3e378) to wood_a/wood_b/scrub_a/scrub_b as well as park_a/park_b —
but in Park Tiles' own Imagery style, that green is specifically the
protected-area/administrative color; actual vegetation there is just the
aerial photo underneath, which this offline style has no fill equivalent
of. Applying it everywhere made the whole map read as green instead of
"brown with green parks popping out", which is the actual character of
their palette. Forests/scrub now render in muted brown/khaki instead,
leaving the vivid green for park_a/park_b alone.
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.
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.
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.
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.
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.
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.
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.
Secondary metadata, not something worth visually competing with the actual
description — dropped the bold label weight and colored the whole meta
block the same muted gray already used for category/time elsewhere in the
popup.
A park4night <desc> packs a free-text narrative and several lines of
structured data (address, rating, services, activities, the listing URL)
into one field, separated only by newlines. Rendered as a single paragraph
it read as a wall of run-together text — no visual hierarchy between the
actual description and what's really key-value metadata.
formatDescription now recognizes "Label: value" lines (Rating/Services/
Activities) and bare-URL lines, and gives each its own row instead of
burying them in the narrative paragraph: a bordered meta block with bold
labels, and an isolated link line. Anything that doesn't match — the real
narrative text, or a plain GPX file's <desc> with no such structure — stays
exactly as it rendered before, so this only adds structure, never removes
or reorders text.
The badge and category glyphs were registered as SDF images (a single alpha
shape MapLibre tints at render time via icon-color) purely so one shared
sprite could be recolored per category. But SDF rendering assumes the
source actually encodes a distance field — a smooth multi-pixel gradient at
every edge — and these were just normally antialiased rasters (a sharp ~1px
transition). MapLibre's SDF antialiasing pass softened every edge as a
result, visibly blurrier than the base map's own icons, worst on the small
category glyph.
The glyph color never varies (always white), so it never needed runtime
tinting at all: _registerCategoryGlyphs now bakes #fff directly into the
raster and registers one plain image per category. The badge color does
vary (arbitrary per-file hex), but a real POI export only has a handful of
distinct category colors, so _registerBadgeColors rasterizes one sharp
image per distinct color actually present in the points being shown,
cached by color, instead of one shared image tinted at render time.
The National Park Service frames its own map symbols (the icons already
vendored in for POI categories) in a rounded square, not a pin — pairing a
pin shape with NPS-style pictograms looked like two mismatched icon
conventions. Switched the marker to a rounded square to match.
This also simplifies the placement code: a pin has to anchor at its tip, so
the category glyph on top needed an offset computed from the pin/glyph
geometry to land in the pin's head. A square has no tip — both the badge and
the glyph layer just center directly on the coordinate with no offset math
at all.
Colored circles for waypoints looked too similar to a base map style's own
small circular POI icons, so uploaded POI data blended into the map instead
of standing out. Switched the marker shape to a pin/teardrop (reusing the
existing WAYPOINT_ICON path, filled instead of stroked) — no base map style
uses that shape for its own points, so it reads immediately as "your data".
Anchored at its tip, the standard map-pin convention, rather than centered
on the coordinate.
Also swapped the hand-drawn category glyphs for the National Park Service's
own map symbols (github.com/nationalparkservice/symbol-library, BSD
licensed, forked from Mapbox's Maki) for the six categories it covers —
professional, purpose-built pictograms beat ad-hoc "fuel pump"/"picnic
table" line art. `forest` has no NPS equivalent and stays hand-drawn.
The glyph now sits in the pin's head rather than centered on a flat dot,
which needed real geometry (both layers anchor to the same point, so the
offset is the gap between where the pin's head-center ends up and where the
glyph's own visual center already sits) rather than the old anchor="center"
zero-offset placement.
A park4night/OsmAnd POI export (e.g. a p4n2gpx area export) tags every
waypoint with a category and a per-category color/icon in its <extensions>
block, all of which was previously thrown away — every waypoint rendered as
the same plain red dot, unreadable once a file has a few thousand of them.
Maptool::Gpx now captures category/color/icon (waypoint_style), matched by
namespace URI rather than local element name: a p4n2gpx file carries both
<osmand:icon> and <locus:icon> side by side, and REXML strips prefixes from
Element#name, so both report as just "icon" — matching on that alone was
silently picking the (useless) Locus drawable path instead of the OsmAnd
category key. Regression test added for this.
On the map: waypoints are colored by category (falling back to the plain
track color for anything uncategorized), a small hand-drawn icon set
(public/js/icons.js's POI_ICONS, rasterized to SDF sprites at runtime) draws
a glyph on top for the categories it covers, the points source is clustered
so a dense export collapses into count bubbles instead of a solid blob, and
a legend in the side panel lists whichever categories are currently visible.
Splitting each file's GeoJSON into a lines source and a points source
(rather than one mixed source with a geometry-type filter per layer) was
needed for clustering, since MapLibre's cluster support only understands a
source made entirely of points.
GPX tracks were silently failing to render for large real-world files:
point_extensions used REXML's "extensions//*" XPath, and the // descendant
axis costs low-single-digit milliseconds per call regardless of subtree
size. Run once per point, that's negligible on small files but ~30s on a
real 8000-point track — long enough to hit a proxy timeout in production,
so the file just never appeared with no error. Replaced with plain
recursive child traversal (~40x faster on the same file, confirmed
29.9s -> 3.16s); test_parsing_many_points_with_extensions_stays_fast
guards against reintroducing it.
Also adds support for <rte> routes (not just <trk> tracks and <wpt>
waypoints), sharing the track/route conversion logic via a new
line_feature helper. Routes get their own "route" kind and a dashed
ROUTE_ICON in the file tree so the type is visible at a glance.
_addLayers handed MapLibre a URL and let it fetch the GeoJSON
internally — a 422 (a file REXML/Fit can't parse) just meant the
layer never appeared, with nothing telling the user why. It now
fetches the file itself first, alerts with the actual error and
un-checks the box on failure.
TrackStats.build_profile now also computes elapsed_secs per point
(relative to the first known timestamp), alongside the existing
cumulative distance. Point x-positions stay distance-based either
way — only the shared axis strip's 5 tick labels (and the tooltip)
switch between the two, so toggling is a cheap re-render rather than
rebuilding the charts. The toggle only appears when a track actually
has elapsed-time data to switch to.
Plain HTML overlays, not SVG text: the chart's viewBox scales width
and height independently (preserveAspectRatio="none", for a
responsive width at a fixed pixel height), so text drawn inside that
coordinate space would get stretched by the mismatched factor.
Track properties gain activity_type (FIT: sport enum from the
session/sport message, Garmin's own table; GPX: <trk><type> passed
through as-is) alongside the existing distance/elevation/duration.
Clicking a track line now opens a bottom panel with a chart per
metric the track actually has (elevation always; speed/cadence/heart
rate only when present — FIT's record fields or GPX's device
extensions). TrackStats.build_profile computes cumulative distance
the same way for both formats so they're directly comparable. One
pointermove listener drives a synchronized crosshair across every
chart, a single tooltip listing all series, and a map marker at the
hovered point's position — hovering the chart shows where on the
track that moment was.
`bundle exec rake test` (minitest, a default gem but pinned in
Gemfile for reproducibility) now covers what ad-hoc scripts kept
re-verifying by hand during development: Gpx/Fit/TrackStats parsing,
GpxStore/LiveTrackStore file/TTL logic, LiveTrackClient's payload
parsing, and app.rb's routes end to end via Rack::MockRequest.
MAPTOOL_DATA_DIR (new, app.rb) lets the suite point GPX_STORE/
LIVETRACK_STORE at a throwaway directory instead of real data/ —
without it every run would leave uploaded test files behind. Each
lib file's old `if __FILE__ == $0` self-check is now redundant with
real tests covering the same ground, so removed rather than kept as
a second copy of the same assertions. Wired into ci.yml so it isn't
just a local convenience.
Maptool::TrackStats computes these once, shared by Gpx and Fit so
both track feature shapes carry the same properties regardless of
source format. Hovering a track line (not clicking — these describe
the whole line, not one point) shows them in a popup, same mechanism
GPX waypoints already use.
Maptool::Fit reads Garmin's binary FIT format directly rather than
adding a gem — the one Ruby option depends on activesupport, unmaintained
since 2022, for a single-purpose binary parser. Only "record" messages
are read (position + altitude; everything else is skipped by byte
length, never interpreted); field numbers and scale/offset are
Garmin's own (fit-python-sdk's profile.py), and the framing plus all
extracted values were checked byte-for-byte against 5 real device
files from python-fitparse's test suite before this shipped.
A .zip upload (Garmin's bulk/FIT export format) is expanded into its
.gpx/.fit entries via rubyzip — that one's an actual dependency, since
ZIP's own format isn't the kind of narrow, single-purpose parsing FIT
above is.
Opening a session that's still recording (active in its summary) now
re-fetches every 30s and updates the map in place, instead of freezing
at whatever the track looked like when first opened. Stops on its own
once the session ends, or immediately if switched away from.
A session's own end timestamp is absent until the activity is
actually stopped, so its presence/absence is the status signal — no
new data needed, just reading what was already there. Shown as a
badge next to the session in the list and in its map hover popup.
The track endpoint now merges the session summary into the marker's
GeoJSON properties (already computed for storage, just wasn't passed
through), and the marker gets the same hover-popup treatment GPX
points already have.
Fetching a session's track now also grabs its summary (name, activity
type, distance, duration) from the same page and stores it — sessions
the panel has actually shown replace their raw UUID with that summary,
and are kept indefinitely regardless of age. A session nobody ever
opened (no summary) is more likely test/dead data than something worth
keeping, so it's dropped automatically after 48h.
Clicking a LiveTrack session now fetches and draws its track (line +
latest position). Garmin's own API 403s any plain server-side request
(Cloudflare bot protection), so LiveTrackClient fetches the session
page instead and pulls the track out of the server-rendered data
embedded in it. The webhook now captures the session's token too,
since the id alone isn't enough to fetch anything.
Browsers (and a caching proxy in front of production) were holding
onto /js and /css for hours, so a deploy could go unnoticed by an
already-open tab. A boot-time version query string on the entry
script/stylesheet, plus an import map remapping map.js's internal
module imports to the same versioned URLs, fixes that without editing
every JS file's import statements.