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.
749 lines
42 KiB
Plaintext
749 lines
42 KiB
Plaintext
= Architecture
|
||
:toc:
|
||
:toclevels: 2
|
||
|
||
== Overview
|
||
|
||
MapTool is a Sinatra web application, served locally by Puma and
|
||
opened in a browser.
|
||
|
||
== Directory layout
|
||
|
||
[cols="1,3"]
|
||
|===
|
||
| Path | Purpose
|
||
|
||
| `app.rb`, `config.ru`
|
||
| Sinatra application and Rack entry point.
|
||
|
||
| `config/puma.rb`
|
||
| Puma server configuration (binds to `127.0.0.1` only — the app is
|
||
local-only, not exposed on the network).
|
||
|
||
| `public/`, `views/`
|
||
| Frontend assets and Sinatra templates. The map view uses MapLibre GL
|
||
JS, rendering vector tiles.
|
||
|
||
| `public/vendor/`
|
||
| Third-party frontend libraries, vendored (not loaded from a CDN) so
|
||
the app also works with no internet access. See
|
||
`docs/third-party-notices.adoc`.
|
||
|
||
| `data/`
|
||
| Local, gitignored data files, e.g. `offline.pmtiles` and uploaded
|
||
GPX/FIT files under `data/gpx/`.
|
||
|
||
| `lib/maptool/`
|
||
| Application code beyond routing: the GPX/FIT parsers and file store.
|
||
|
||
| `docs/`
|
||
| Project documentation, in AsciiDoc.
|
||
|===
|
||
|
||
== Offline maps
|
||
|
||
Map data is vector, not raster: the app ships
|
||
https://maplibre.org[MapLibre GL JS] (vendored, see below) and the map
|
||
view (`public/js/map.js`) offers two styles:
|
||
|
||
* *Online* — a full hosted vector style,
|
||
https://tiles.openfreemap.org/styles/liberty[OpenFreeMap "liberty"],
|
||
requires internet access.
|
||
* *Offline* — built at runtime from a local
|
||
https://github.com/protomaps/PMTiles[PMTiles] archive at
|
||
`data/offline.pmtiles`, served as-is (see below) at
|
||
`/tiles/offline.pmtiles`, decoded client-side by
|
||
https://github.com/protomaps/PMTiles[pmtiles.js] registered as a
|
||
`pmtiles://` protocol handler, and styled with base layers from
|
||
https://github.com/protomaps/basemaps[protomaps-themes-base].
|
||
|
||
`GET /api/offline-tiles` reports whether `data/offline.pmtiles` is
|
||
present (`{"available": true|false}`); the frontend uses it once, on
|
||
load, to default to the offline style when local tile data exists.
|
||
When it doesn't, `LayerToggleControl#remove()` (`public/js/map.js`)
|
||
removes the Online/Offline sidebar icon entirely — a switch offering a
|
||
choice that can't work is worse than no switch at all — so it's only
|
||
ever shown on an install that actually has a PMTiles file to switch to.
|
||
|
||
PMTiles is a single, self-indexed file, which is why this needs no
|
||
tile-decoding logic on the server: `app.rb`'s `/tiles/offline.pmtiles`
|
||
route (`send_file`, which Sinatra serves with HTTP range-request
|
||
support) just returns byte ranges of the file; pmtiles.js in the
|
||
browser reads the archive's own directory and requests only the bytes
|
||
it needs for the tiles on screen.
|
||
|
||
=== Offline text labels and color palette
|
||
|
||
Label rendering in MapLibre needs a "glyphs" server serving font PBF
|
||
ranges (256 Unicode code points each); `data/offline.pmtiles`'s fields
|
||
(`name`, `name:en`, `script`, ...) match what
|
||
`protomaps-themes-base`'s `layers(source, theme, { lang: "en" })`
|
||
expects for its label layers (verified against the actual PMTiles
|
||
vector layer schema), so passing that option turns labels on. The
|
||
glyphs themselves are vendored under `public/vendor/fonts/` — Noto
|
||
Sans Regular/Italic/Medium, ranges `0-255` and `256-511`
|
||
(Basic Latin + Latin-1 Supplement + Latin Extended-A, SIL OFL
|
||
licensed, from
|
||
https://github.com/protomaps/basemaps-assets[protomaps/basemaps-assets]),
|
||
covering accented Latin place names (Slovak, Czech, Polish, German,
|
||
...). Labels in other scripts (Cyrillic, Greek, CJK, ...) won't render
|
||
offline, since those glyph ranges aren't vendored.
|
||
|
||
`public/js/map.js`'s `OFFLINE_THEME` nudges a couple dozen color roles
|
||
from `protomaps-themes-base`'s stock "light" theme towards the
|
||
National Park Service's own "Park Tiles" cartography — a warm,
|
||
Harpers-Ferry-Center-derived look. A few of these values are the
|
||
genuine paint colors pulled from a public Park Tiles style JSON
|
||
(`nps.gov/lib/npmap.js/style/ck72fwp2642dv07o7tbqinvz4.json`, the
|
||
"Imagery" variant — 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) — background, the park/protected-area green, the
|
||
two road-importance tiers, and the park-boundary line color. The rest
|
||
are reasoned to match that same warm family (olive greens, muted blue)
|
||
rather than sourced. This is deliberately partial — labels, casings,
|
||
and roles with no clean real-color anchor are left at theme
|
||
defaults — not the kind of full per-layer recolor a previous iteration
|
||
of this app did (and removed, along with any dependency on OsmAnd's
|
||
own tile server, which this app cannot use per OsmAnd's terms
|
||
restricting tile use to "personal, non-commercial use within OsmAnd
|
||
applications and platforms").
|
||
+
|
||
Recreating the *color choices* is fine — that's not the kind of thing
|
||
a data license restricts — but the actual Park Tiles *service* stays
|
||
off-limits: its OSM/terrain layers are licensed through Mapbox Atlas
|
||
(a commercial, non-sublicensable data service scoped to NPS), and it's
|
||
built for park visitors specifically (park-unit and trailhead
|
||
labeling prioritized), not general-purpose worldwide cartography.
|
||
|
||
No PMTiles file ships with the repository — it is expected to be
|
||
supplied locally (see `docs/development.adoc`) and stays gitignored
|
||
because such extracts are typically large binary files.
|
||
|
||
The Online map (OpenFreeMap Liberty) gets the same treatment via
|
||
`ONLINE_STYLE_PATCH`, a separate hand-built table — Liberty is an
|
||
OpenMapTiles-schema style, with its own layer ids (`landcover_wood`,
|
||
`road_secondary_tertiary`, `boundary_2`, ...) that don't correspond to
|
||
protomaps' theme roles, so the two palettes can't share one object,
|
||
only the same underlying color choices. `loadOnlineStyle`
|
||
(`public/js/map.js`) fetches Liberty's `style.json` once, patches it
|
||
in place, and uses the resulting object everywhere `ONLINE_STYLE` used
|
||
to be a plain URL string — MapLibre accepts a style object anywhere it
|
||
accepts a style URL, so `applyStyle`'s `map.setStyle(... : ONLINE_STYLE)`
|
||
needed no change. That fetch runs before the map (and the rest of the
|
||
page) is constructed — a top-level `await` — so there's no flash of
|
||
Liberty's own colors before the patch lands; a 3s timeout and a
|
||
catch-all fall back to the plain URL (letting MapLibre fetch and
|
||
render it unpatched) rather than block the whole page's startup on a
|
||
remote style server, since this app is meant to keep working offline
|
||
in the field, independent of whether the online map itself loads.
|
||
Only the layers that dominate the map's overall color read are
|
||
patched (land cover, water, parks, roads, boundaries, buildings) —
|
||
same deliberately-partial scope as `OFFLINE_THEME`, not a full
|
||
per-layer recolor. Road casings are the one addition with no
|
||
protomaps-role equivalent to reuse, since offline roads don't get a
|
||
separate outline color there; `#c9a66b` is reasoned to fit the family
|
||
rather than sourced from anywhere real.
|
||
|
||
== Map permalink
|
||
|
||
The map's `hash: "map"` option (`public/js/map.js`) turns on MapLibre
|
||
GL JS's built-in `Hash` control — nothing hand-rolled here. It keeps
|
||
`#map=<zoom>/<lat>/<lon>` in the URL in sync with the current view (via
|
||
`history.replaceState`, so panning never spams the back button) and,
|
||
if the page loads with one already in the URL, opens there instead of
|
||
the default `CENTER`/`ZOOM` — the same permalink pattern OSM and Google
|
||
Maps use, so copying the URL bar shares the exact spot someone's
|
||
looking at, and reloading the page doesn't reset the view back to the
|
||
default.
|
||
|
||
Now that any view can be the starting one (via that permalink), a way
|
||
back to the app's actual default is worth having: a "Home" button
|
||
(`public/vendor/npmap-home-button/`, the National Park Service's own
|
||
`home-button` MapLibre control — see `docs/third-party-notices.adoc`)
|
||
sits top-left on the map. It fits to `SLOVAKIA_BOUNDS` (a country-wide
|
||
bounding box) rather than the plain `CENTER`/`ZOOM` point the map
|
||
itself starts at — "home" is meant as a full-country overview, not a
|
||
re-centering on Bratislava. It resets to that fixed default view, not
|
||
the visitor's own location — that's `LocateControl`, a separate button
|
||
in the same stack (below).
|
||
|
||
That Home button is the top of a small top-left stack — Home, zoom
|
||
in/out (`NavigationControl`, `showCompass: false` since this map never
|
||
rotates), a passive zoom-level readout (`ZoomLevelControl`, a plain div
|
||
updated on the map's `zoom` event — not a button, since it has no
|
||
click behavior of its own), a distance/area measuring tool
|
||
(`MeasureControl`, `public/js/measure.js`), locate-me
|
||
(`LocateControl`, `public/js/locate.js`), and a "Base map" button —
|
||
matching the National Park Service's own map control layout (grouped
|
||
together on the map itself, rather than split across corners or
|
||
between the map and a side panel). The last of those,
|
||
`MapLayersButton` (`public/js/map.js`), is a second entry point into
|
||
the exact same Online/Offline panel
|
||
`LayerToggleControl` already manages in the page's own sidebar — both
|
||
call the same `LayerToggleControl#toggle()`, so there's one source of
|
||
truth for the panel's open/closed state either way.
|
||
|
||
A separate control sits top-right: a small inset overview map
|
||
(`public/vendor/npmap-overview/`, NPS's `overview` plugin — see
|
||
`docs/nps-resources.adoc`) showing the current view's location in a
|
||
wider, zoomed-out context, with the visible area drawn as a box on top
|
||
of it; dragging that box pans the main map. It clones whichever base
|
||
style the main map is currently showing (`style: ''`, its own default —
|
||
the plugin reads `map.style.serialize()`) rather than a separate style
|
||
of its own, so it stays visually consistent with the Online/Offline
|
||
toggle without this app maintaining a second style for it. Vendored
|
||
from an unmerged upstream branch and patched for a MapLibre API break
|
||
(it targeted MapLibre ~2.4's public `map.transform`, since removed) —
|
||
see `docs/third-party-notices.adoc` for the exact patch.
|
||
|
||
=== Measuring distance and area
|
||
|
||
`MeasureControl` (`public/js/measure.js`) opens a small floating
|
||
panel next to its ruler button (mirroring NPS's own Area/Distance
|
||
ruler) with Distance/Area mode buttons, a live result, and Clear —
|
||
its buttons needed `.measure-panel <selector>` (not just the bare
|
||
class) to outrank `.maplibregl-ctrl-group button`'s own 29×29px/
|
||
zero-padding rule, which otherwise clips "Distance"/"Area" into
|
||
overlapping text (the container is a `maplibregl-ctrl-group` for the
|
||
ruler button's own sizing, and that rule reaches every button nested
|
||
inside it, panel included). Clicking the map adds a vertex; distance
|
||
sums haversine segment lengths, area uses the spherical-excess
|
||
polygon formula (Chamberlain & Duquette — the same one turf.js's
|
||
`area()` uses) rather than a flat shoelace, since a flat approximation
|
||
drifts at the scale a park boundary can reach. In distance mode, each
|
||
individual segment also gets its own label at its midpoint (a `symbol`
|
||
layer keyed off a `role: "segment-label"` property, filtered apart
|
||
from the draggable vertex points, which share the same source but
|
||
`role: "vertex"`) — the running total in the panel doesn't say how
|
||
long any one leg was on its own. Area mode has no equivalent yet;
|
||
a polygon's edges are its perimeter, not a path, so the same treatment
|
||
may not even read the same way there.
|
||
|
||
=== Finding your location
|
||
|
||
`LocateControl` (`public/js/locate.js`) replaces MapLibre's own
|
||
`GeolocateControl` — same button, reusing its exact CSS classes/icon
|
||
(`.maplibregl-ctrl-geolocate`) rather than a new one, so it's a visual
|
||
drop-in — but sourced differently. This app has its own GNSS receiver
|
||
(gpsd, possibly `bin/gnss_simulator.rb`'s simulated one — see
|
||
`docs/development.adoc`/`docs/deployment.adoc`), and "find my
|
||
location" should mean that receiver first, not a device's separate
|
||
(and often less accurate, e.g. wifi-based) location service, when the
|
||
receiver actually has a fix. Clicking it checks
|
||
`GET /api/gnss/position` once: if `available`, it polls that
|
||
endpoint (same 1.5s cadence as `GnssControl`'s own sidebar polling)
|
||
and shows the fix with the same `.gnss-marker` styling that control
|
||
uses; if not, it falls back to the browser's own
|
||
`navigator.geolocation.watchPosition`, GeolocateControl's own
|
||
mechanism. Either way, only the *first* fix recenters the map —
|
||
later updates move the marker without fighting a pan/zoom the visitor
|
||
made in the meantime, unlike GeolocateControl's own default of
|
||
recentering on every update. Clicking again stops tracking (clears
|
||
the poll timer or browser watch, whichever was active) and removes
|
||
the marker.
|
||
|
||
=== Debug inspector (Alt+click)
|
||
|
||
Alt+click anywhere on the map opens a popup listing every rendered
|
||
feature at that point — across all layers, not just this app's own GPX/
|
||
FIT/radar layers — with its raw properties (`public/js/map.js`, a plain
|
||
`map.on("click", ...)` handler gated on `event.originalEvent.altKey`,
|
||
calling `map.queryRenderedFeatures(event.point)`). Inspired by NPMap4's
|
||
`infoboxControl`, a similar generic "show me everything here" debug
|
||
view kept separate from the curated popups those same features already
|
||
have. Deliberately no dedicated UI for it — a keyboard-modified click
|
||
needs no button, toggle state, or cursor change of its own, and it
|
||
coexists fine with layer-specific click handlers (e.g. a GPX waypoint's
|
||
normal popup) firing on the same click, since this is a power-user/
|
||
debugging affordance, not a replacement for those. Feature properties
|
||
can come straight from an uploaded GPX/FIT file (untrusted input) via
|
||
this app's own layers, not just the base map, so every key/value is
|
||
HTML-escaped the same way `gpx.js`'s popups already are.
|
||
|
||
== Weather radar overlay
|
||
|
||
`public/js/radar.js` adds a "Radar" map control that overlays recent
|
||
precipitation radar from https://www.rainviewer.com[RainViewer]'s free
|
||
public API (no API key required). Enabling it:
|
||
|
||
. Fetches `https://api.rainviewer.com/public/weather-maps.json`,
|
||
which lists RainViewer's past radar frames (fixed 10-minute
|
||
cadence, at most 13 covering the last 120 minutes).
|
||
. Takes the most recent 7 (last ~60 minutes — the same default as
|
||
the https://github.com/jpettitt/weather-radar-card[Home Assistant
|
||
weather-radar-card], for a consistent feel with that setup) and
|
||
adds one MapLibre raster source/layer per frame, all pre-loaded but
|
||
only the current frame's `raster-opacity` non-zero.
|
||
. Animates by toggling that opacity across frames on an interval
|
||
(play/pause + a scrub slider), rather than swapping tiles in and out
|
||
— avoids reloading tiles on every step.
|
||
|
||
Tile URL: `{host}{frame.path}/256/{z}/{x}/{y}/2/1_0.png` — `2` is
|
||
RainViewer's "Universal Blue" color scheme, `1` enables their
|
||
smoothing, `0` disables the snow overlay. `host` and each frame's
|
||
`path` come from the API response.
|
||
|
||
Since `map.setStyle()` (switching Online/Offline) tears down all
|
||
custom sources and layers, the control listens for `style.load` and
|
||
re-adds the radar layers if the overlay was on.
|
||
|
||
This is a live, online-only overlay — it always needs internet access
|
||
and is unrelated to the offline PMTiles basemap above. Per RainViewer's
|
||
terms, the API is free for personal/non-commercial use.
|
||
|
||
== GPX/FIT/GeoJSON tracks and waypoints
|
||
|
||
The "Tracks" map control (`public/js/gpx.js`) opens a side panel to
|
||
upload GPX, FIT or GeoJSON files by dragging them in, organize them
|
||
into folders (also by dragging), and toggle each one on the map
|
||
individually.
|
||
|
||
Storage:: Files are stored as-is under `data/gpx/`, in a
|
||
folder hierarchy the user manages directly — `Maptool::GpxStore`
|
||
(`lib/maptool/gpx_store.rb`) provides `save`, `create_folder`,
|
||
`delete_file`, `delete_folder` and `move`. Every path coming from the
|
||
client (a folder to create/upload/move into, a file path in a URL) is
|
||
resolved with `File.expand_path` and checked against the store root
|
||
before touching the filesystem, so `../..`-style path traversal can't
|
||
escape `data/gpx/`; `delete_folder` additionally refuses an empty path
|
||
or one that resolves to the store root itself, so it can never `rm
|
||
-rf` the whole store.
|
||
`GET /api/gpx/tree` returns the resulting folder/file tree as JSON,
|
||
which the panel renders recursively (folders as `<details>`, files as
|
||
a checkbox, both with a delete button).
|
||
|
||
Drag & drop:: The dropzone at the top of the panel accepts one or more
|
||
`.gpx`/`.fit`/`.geojson` files, or a `.zip` (`POST /api/gpx`,
|
||
multipart) into the top level, or click it to browse instead. A
|
||
`.geojson` file needs no format conversion — it's already the shape
|
||
`gpx.js` expects — so `GpxStore#geojson` just serves it straight from
|
||
disk (see "Rendering" below); useful for a file already in this app's
|
||
own schema, e.g. one exported elsewhere in that shape, or a previous
|
||
`GET /api/gpx/file` response saved and re-uploaded. A `.zip` —
|
||
Garmin's bulk/FIT export format — isn't stored itself:
|
||
`save_zip_entries` (`app.rb`) expands it with `rubyzip` and saves each
|
||
`.gpx`/`.fit`/`.geojson` entry it contains individually (flattened —
|
||
any folder structure inside the zip is discarded), skipping anything
|
||
else (readme files, etc.) it might hold. An already-uploaded file (a
|
||
`.gpx-tree__file` row, `draggable`) can be dragged onto a folder to
|
||
move it there (`POST /api/gpx/move`), or dropped anywhere else in the
|
||
tree to move it back to the top level; an OS file can also be dropped
|
||
directly onto a folder to upload straight into it. Both drag sources
|
||
land on the same drop targets and are told apart by a custom
|
||
`application/x-gpx-path` drag payload type versus the browser's own
|
||
`"Files"` type. If a file that's currently checked (visible on the
|
||
map) gets moved, its map layers are re-added under the new path so it
|
||
stays visible; if it (or its containing folder) gets deleted, its
|
||
layers are removed instead.
|
||
|
||
Rendering:: Checking a file fetches `GET /api/gpx/file/<path>`, routed
|
||
by extension to `Maptool::Gpx` or `Maptool::Fit`
|
||
(`lib/maptool/gpx.rb`/`fit.rb`) for parsing, and both convert to the
|
||
same GeoJSON `FeatureCollection` shape so the frontend doesn't need to
|
||
know which format it got. `GpxStore#geojson` caches that conversion
|
||
alongside the source file (`<path>.geojson`, invalidated by mtime) —
|
||
REXML parsing a several-thousand-waypoint POI export (a park4night
|
||
area export, say) can take tens of seconds, and without a cache that
|
||
cost repeats on every single toggle. `GpxStore#save` also calls
|
||
`geojson` itself right after writing the file, so that cost is paid
|
||
once during the upload (covered by the frontend's own "Uploading…"
|
||
status) instead of on whichever toggle happens to be first — a silent
|
||
multi-second hang on a checkbox click reads as broken in a way the
|
||
same wait during an upload doesn't. A file that fails to parse still
|
||
saves fine either way; the error just surfaces on first view instead.
|
||
A `.geojson` upload skips this path entirely — `geojson` reads and
|
||
returns it directly, no cache file, no `Gpx`/`Fit` involved.
|
||
`GpxStore#save` converting eagerly means `cached` is `true` for
|
||
almost everything in the tree — the one exception is a `.gpx`/`.fit`
|
||
dropped straight into `data/gpx/` on the filesystem, bypassing the
|
||
app, and not yet opened. Badging the common case would just be noise,
|
||
so the file tree's icon (`PENDING_ICON`, `public/js/icons.js`) marks
|
||
that exception instead of the (near-universal) ready state.
|
||
+
|
||
The tree always shows a `.gpx`/`.fit` file under its real, as-uploaded
|
||
name, even once cached — it briefly grew a `.geojson` suffix there
|
||
(matching the real cache file's name on disk) back when the name
|
||
doubled as a GeoJSON-only download link and the suffix explained what
|
||
clicking it would get you; now that downloading is the download
|
||
button's job (below), the suffix was just confusing; the file itself
|
||
is still named `"ride.gpx"`. The original is never deleted or
|
||
re-parsed once cached, just not re-shown as itself once the GeoJSON
|
||
becomes what's actually rendered on the map, so both forms need a way
|
||
out of the app as an actual file: one download button, next to
|
||
delete, opens a small menu ("Original file" / "GeoJSON",
|
||
`_downloadButton`/`_downloadMenuItem`). Only one menu open at a time
|
||
(`_toggleDownloadMenu`/`_closeDownloadMenu`), closed by a
|
||
document-level click listener the same way an outside click closes a
|
||
`sidebar-flyout` dropdown, just scoped to this one control rather than
|
||
threaded through `onlyOneSidePanelOpen`. A directly-uploaded `.geojson`
|
||
keeps its own name and both menu items download the same (identical)
|
||
content — redundant there, not wrong. Both downloads (`GET
|
||
/api/gpx/file/<path>?download=original`/`?download=geojson`) require
|
||
login, same as upload/move/delete/create-folder, so the whole button —
|
||
not just its menu items — is left out of the DOM when logged out
|
||
rather than shown and failing on click (see "Authentication" below);
|
||
`GET /api/gpx/file` itself stays open to anyone otherwise (viewing a
|
||
file on the map isn't a write action), just not `?download=...` on it.
|
||
Each menu item is a real `<a href>` (so right-click/middle-click still
|
||
do something sensible), but a plain navigation would land a logged-out
|
||
click on a raw JSON error page instead of being sent to log in —
|
||
`_download` (`gpx.js`) fetches instead, checks for a 401 the same way
|
||
`redirectIfUnauthorized` already does for every other write action,
|
||
and only then turns the response into a blob and clicks a throwaway
|
||
`<a download>` to trigger the actual save. `Gpx` parses the GPX with REXML: `<wpt>`
|
||
waypoints become `Point` features (with `name`, `desc` and `time`
|
||
carried through as properties), each `<trk>` becomes one
|
||
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
|
||
becomes one too (a route is a single continuous line by definition, no
|
||
`<trkseg>`-style grouping) — the two are told apart only by a `kind`
|
||
property (`"track"` vs `"route"`; waypoints are `"waypoint"`), which
|
||
`Gpx.kinds` also exposes per-file so the file tree can show a dashed
|
||
route icon next to a route file and a solid one for a track
|
||
(`public/js/icons.js`'s `ROUTE_ICON`/`TRACK_ICON`,
|
||
`public/js/gpx.js`'s `_kindIcon`). `<rte>` has no GPX equivalent of
|
||
`<trk>`'s `<type>`, so a route's `activity_type` is always `nil`.
|
||
`Fit` reads Garmin's binary FIT format directly
|
||
(hand-rolled — see below) into a single `MultiLineString` `track`
|
||
feature; FIT activity files don't really have a GPX-waypoint
|
||
equivalent, so it never produces `Point` features. Checking a file
|
||
fetches its GeoJSON itself (`public/js/gpx.js`'s `_addLayers`) and
|
||
hands the parsed result to `addSource` — rather than passing MapLibre a
|
||
URL and letting it fetch internally — specifically so a `422` (a file
|
||
REXML or `Fit` can't parse) surfaces as an alert and un-checks the box
|
||
instead of the layer just silently never appearing, which is what a
|
||
`data: url` source does on a failed fetch. The fetched features are
|
||
then split client-side into two GeoJSON sources per file, a
|
||
lines-and-routes one and a points-only one, rather than one mixed
|
||
source with a `geometry-type` filter per layer (the previous design):
|
||
MapLibre's built-in point clustering (below) only understands a source
|
||
made entirely of `Point`/`MultiPoint` features, so a file mixing a
|
||
track with waypoints — or a several-thousand-waypoint POI export with
|
||
no line at all — needs the points on their own source regardless of
|
||
what else the file's GeoJSON also carries.
|
||
|
||
POI category styling & clustering:: A POI export (park4night via
|
||
p4n2gpx, OsmAnd) tags each waypoint with a category (`<type>`) and, in
|
||
a vendor `<extensions>` block, a per-category color and OsmAnd icon key
|
||
(e.g. `amenity_parking`) — `Maptool::Gpx.waypoint_style` reads both
|
||
into `category`/`color` properties (an OsmAnd export can carry a
|
||
`<locus:icon>` alongside `<osmand:icon>`, both of which strip down to
|
||
the same local element name "icon" once REXML drops namespace
|
||
prefixes, so this specifically matches on namespace *URI*
|
||
(`https://osmand.net`), not local name, to avoid picking whichever
|
||
happens to come last in the file). Individual waypoints render as a
|
||
rounded-square badge (`public/js/icons.js`'s `MAP_MARKER_ICON`) rather
|
||
than a plain circle — a flat colored dot turned out to look too much
|
||
like a base map style's own small circular POI icons, blending in
|
||
instead of reading as "your data". A rounded square rather than a
|
||
pin/teardrop: it matches how the National Park Service itself frames
|
||
these exact category symbols (below) on its own maps, so the two look
|
||
native together instead of like mismatched icon styles. `_addLayers`
|
||
defaults a point's `color` to the plain track color right on the
|
||
feature (`feature.properties.color ||= TRACK_COLOR`) before it ever
|
||
reaches a layer, rather than a `coalesce` in a style expression — see
|
||
below for why. Unlike a pin, which has to anchor at its tip, the badge
|
||
is centered directly on the coordinate with plain default anchoring,
|
||
which also means the category-glyph layer on top needs no offset math:
|
||
both layers just share the same center anchor at `[0, 0]`. A small
|
||
curated icon set (`public/js/icons.js`'s `POI_ICONS`, covering the
|
||
categories p4n2gpx actually uses, not an exhaustive OsmAnd icon set)
|
||
draws a white glyph on top of the badge for any waypoint whose `icon`
|
||
matches a known key (filtered out entirely for anything else, so an
|
||
unrecognized icon key just falls back to a plain colored badge rather
|
||
than a broken/missing image warning). All seven glyphs are the National
|
||
Park Service's own map symbols — pulled from
|
||
`github.com/nationalparkservice/npmap5-plugins`'s bundled
|
||
`symbol-library` (CC0-licensed, an actively-maintained monorepo that's
|
||
the designated replacement for the same org's older standalone
|
||
`symbol-library` repo, per its maintainer) — a professional,
|
||
purpose-built icon set beats hand-drawing "fuel pump"/"picnic table"
|
||
glyphs from scratch.
|
||
+
|
||
Both the badge and the glyphs are rasterized from their source SVGs
|
||
(an `<img>` → `<canvas>` → `ImageData` round trip, since `map.addImage`
|
||
needs a real raster image) as plain, non-SDF images with the actual
|
||
target color already baked into the raster — not SDF images (a single
|
||
alpha shape MapLibre tints at render time via `icon-color`) as this
|
||
first shipped. SDF assumes the source encodes a genuine distance field
|
||
(a smooth multi-pixel gradient at every edge); handed a normally
|
||
antialiased raster (a sharp ~1px transition) instead, MapLibre's SDF
|
||
antialiasing pass made every glyph and badge visibly softer than the
|
||
base map's own icons, worst at the glyph's small icon-size. The glyphs
|
||
are always white, so `_registerCategoryGlyphs` just bakes `#fff` into
|
||
each one and registers a single fixed image per category, once per
|
||
style load — no per-render tinting needed at all. The badge's color is
|
||
per-file data though (an arbitrary hex value from the GPX), so it can't
|
||
be one fixed image; `_registerBadgeColors` instead rasterizes one sharp
|
||
image per *distinct* color actually present in the points being shown
|
||
(`badge-<color>` as the image id, `["concat", "badge-", ["get",
|
||
"color"]]` as the layer's `icon-image`) — cheap in practice, since a
|
||
real POI export only ever has a handful of distinct category colors,
|
||
and results are cached by color so a second file reusing a color
|
||
already seen registers nothing new.
|
||
+
|
||
The points source itself is created with
|
||
`cluster: true`; nearby points collapse into a stepped-radius,
|
||
sequential-blue circle sized by `point_count` below `clusterMaxZoom`,
|
||
clicking one zooms to its `getClusterExpansionZoom()`, and this is
|
||
harmless to enable unconditionally — a source with nothing dense enough
|
||
to cluster just renders every point individually as usual. A small
|
||
legend (`.gpx-legend` in the side panel) lists every distinct category
|
||
+ color currently visible across all checked files, rebuilt from the
|
||
same points GeoJSON kept around for exactly this
|
||
(`GpxPanelControl#_pointsByPath`) whenever a file is toggled on/off; it
|
||
stays hidden for files with no categorized waypoints (a plain GPS
|
||
track has none). The legend swatch color is set via
|
||
`swatch.style.background = color` (a DOM property assignment) rather
|
||
than interpolating the untrusted color string into an `innerHTML`
|
||
attribute — `escapeHtml` (used elsewhere on this page) only guards
|
||
element content, not attribute-quote escaping, so building
|
||
`style="background:${color}"` as a string would have been an
|
||
injection risk for a crafted GPX file.
|
||
|
||
FIT parsing:: No gem was added for this — the one Ruby option
|
||
(`fit-parser`, tjwallace/fit) hasn't been touched since 2022 and pulls
|
||
in `activesupport` for a single-purpose parser, a heavy, stale
|
||
dependency for what this app actually needs. `Maptool::Fit` instead
|
||
reads only "record" messages (global message number 20 — every other
|
||
message type, laps/sessions/events/device info/developer fields, is
|
||
skipped by byte length by summing each field's declared size, never
|
||
interpreted) for 3 fields: `position_lat`/`position_long` (field 0/1,
|
||
`sint32` semicircles, converted to degrees by `* (180 / 2**31)`) and
|
||
`altitude`/`enhanced_altitude` (field 2/78, scale 5 / offset 500 —
|
||
`raw / 5.0 - 500`, in meters). Field numbers and scale/offset are
|
||
Garmin's own (github.com/garmin/fit-python-sdk's `profile.py`), not
|
||
guessed. Both the message framing (headers, definition messages,
|
||
compressed-timestamp headers, little/big-endian) and the 3 fields'
|
||
values were checked against `github.com/dtcooper/python-fitparse`'s
|
||
own test fixtures — 5 real device files spanning 2013-2019, one with
|
||
developer fields, one using only compressed-timestamp headers — byte
|
||
after byte, all coordinates and altitudes exact, before this shipped.
|
||
|
||
`.zip` extraction does use a gem (`rubyzip`) — parsing the ZIP central
|
||
directory format correctly (including compressed entries) isn't the
|
||
kind of narrowly-scoped, single-purpose logic FIT parsing above is,
|
||
and rubyzip is the de facto standard for this in Ruby.
|
||
|
||
Like the radar overlay, GPX layers are removed by `map.setStyle()`
|
||
(switching Online/Offline) and get re-added on `style.load` for
|
||
whichever files were checked.
|
||
|
||
Waypoint popups:: Clicking a waypoint badge opens a MapLibre popup
|
||
with its name, category (if any), photos (if any), description and
|
||
time. Click/hover handling is
|
||
registered once per file (via `map.on(type, layerId, handler)`, a
|
||
delegated listener that survives `setStyle()`) rather than on every
|
||
`style.load` re-add, which would otherwise stack duplicate handlers
|
||
and pop up several copies per click. Waypoint text comes straight from
|
||
an uploaded file — untrusted input — so `public/js/gpx.js`'s
|
||
`linkify()` turns bare URLs in the description into `<a>` links by
|
||
splitting the text on a URL pattern and escaping every piece
|
||
(including the URL itself, as both `href` and link text) before
|
||
assembling any HTML; the URL pattern excludes quote characters, so a
|
||
matched URL can never break out of the `href="..."` attribute. Every
|
||
other value is HTML-escaped before being handed to `Popup#setHTML`.
|
||
+
|
||
A park4night `<desc>` packs a free-text narrative and several lines of
|
||
structured data (address, rating, services, activities, the listing
|
||
URL) into that one field, separated only by newlines — rendered as a
|
||
single paragraph, it read as a wall of run-together text.
|
||
`formatDescription` pulls out whichever lines match a recognizable
|
||
`Label: value` shape (`Rating`/`Services`/`Activities`) or are a bare
|
||
URL, and renders each kind with its own visual treatment (a bordered
|
||
"meta" block with a muted label for the former, an isolated link line
|
||
for the latter); everything else — the actual narrative, or a plain GPX
|
||
file's `<desc>` with none of this structure at all — renders exactly as
|
||
before, one paragraph with line breaks. So this only ever adds
|
||
structure on top of recognized lines; it never loses or hides text from
|
||
files that don't have any.
|
||
+
|
||
A newer p4n2gpx export also emits one plain GPX `<link href>` per
|
||
listing photo (not a vendor extension like `waypoint_style`'s
|
||
`<osmand:*>` — `<link>` is base GPX 1.1, any number allowed per
|
||
`<wpt>`/`<trk>`/`<rte>`). `Gpx.waypoint_photos` collects their `href`s
|
||
into a `photos` property; `formatPhotos` renders them as a row of
|
||
64px thumbnails above the description, each linking to the full-size
|
||
image in a new tab. A plain GPS waypoint or an older export has none,
|
||
so the row just doesn't render — same "only ever adds, never assumes"
|
||
approach as the description formatting above.
|
||
+
|
||
`Services`/`Activities` get one further step (`formatMetaRow`,
|
||
`formatTags`): the value is park4night's own comma-separated codes
|
||
(`animaux, point_eau, wc_public`), which on their own told a reader
|
||
nothing — split on commas and looked up in `PARK4NIGHT_TAGS`
|
||
(`public/js/icons.js`), each renders as a small icon + human label tag
|
||
instead. That table covers the codes p4n2gpx's exports actually use
|
||
(not park4night's whole API), with a human label always and an icon
|
||
only where the icon set below has a reasonable match — most of the
|
||
icons are National Park Service symbols again (same source/license as
|
||
`POI_ICONS`); a couple (bakery, vehicle wash) have no NPS equivalent
|
||
even in that larger set and get a label with no icon. A code this
|
||
table doesn't cover at all just renders as its own raw text, same as
|
||
before this existed — nothing p4n2gpx might add later is silently
|
||
dropped.
|
||
|
||
Track properties:: Every track feature (both formats) also carries
|
||
`distance_meters`, `elevation_gain_meters`, `duration_secs`,
|
||
`point_count` and `activity_type`, computed once server-side by
|
||
`Maptool::TrackStats` (`lib/maptool/track_stats.rb`, shared by
|
||
`Gpx`/`Fit` so neither duplicates the haversine formula) rather than
|
||
in the browser — cheap either way, but it means a GPX and a FIT file
|
||
end up with the same properties shape, so the frontend doesn't need to
|
||
know which format it got. Distance sums the haversine distance between
|
||
consecutive points; elevation gain sums only the positive deltas
|
||
(descents don't subtract); duration is the gap between the first and
|
||
last known timestamp, skipping unknown ones in between — `nil` for any
|
||
of these where the source data doesn't have enough to compute it (e.g.
|
||
a GPX file with no `<time>` elements has `duration_secs: nil`).
|
||
`activity_type` comes from wherever each format keeps it: FIT's
|
||
`session`/`sport` message has a `sport` enum field (`Fit::SPORT_NAMES`
|
||
— Garmin's own table, github.com/garmin/fit-python-sdk's `profile.py`,
|
||
not exhaustive of every value Garmin has defined but every mainstream
|
||
one); GPX's `<trk><type>` is free text, whatever a device/app chose to
|
||
write, passed through as-is. Hovering a track line (rather than
|
||
clicking, unlike a waypoint — these properties describe the whole
|
||
line, not whichever point the cursor happens to be over) shows them in
|
||
the same popup mechanism as waypoints; the popup also hints that
|
||
clicking opens the full profile panel below.
|
||
|
||
Track profile panel:: Clicking a track line opens a bottom panel
|
||
(`public/js/track_profile.js`, `TrackProfilePanel`) with a chart per
|
||
metric the track actually has data for — elevation always (assuming
|
||
any point carries an `<ele>`/altitude), speed/cadence/heart rate only
|
||
when present. `Maptool::TrackStats.build_profile` (called from both
|
||
`Gpx`/`Fit`, alongside `.compute` above) produces this: one entry per
|
||
point with cumulative distance (computed the same way for every
|
||
format, not trusted from a device's own odometer field, so a GPX and a
|
||
FIT track are directly comparable), elapsed time since the first known
|
||
timestamp, elevation, and whatever speed/cadence/heart-rate extras
|
||
that point's format/file carried — `nil` fields simply don't render
|
||
their chart (or, for `elapsed_secs`, don't offer the axis toggle
|
||
below). For FIT, those extras are the `record` message's own
|
||
`heart_rate`/`cadence`/`speed` fields (3/4/6); for GPX, a
|
||
device-specific `<extensions>` block under each `<trkpt>` — usually
|
||
Garmin's `TrackPointExtension`, matched by local element name only
|
||
(`hr`/`cad`/`speed`) so it doesn't care which XML namespace prefix a
|
||
given file happens to declare. That `<extensions>` block is walked by
|
||
hand (a plain recursive `elements.each`) rather than REXML's
|
||
`"extensions//*"` XPath, which this used originally: the `//`
|
||
descendant axis costs low-single-digit milliseconds *per call*
|
||
regardless of how small the subtree actually is, and this runs once
|
||
per point — negligible on a short file, but ~30s for a real
|
||
8000-point one (enough to hit a proxy timeout in production, so the
|
||
track just silently never rendered). Plain traversal is ~40x faster on
|
||
the same file; `test/test_gpx.rb`'s
|
||
`test_parsing_many_points_with_extensions_stays_fast` is a regression
|
||
test for it.
|
||
+
|
||
Each chart is its own single-series `<svg>` (elevation/speed/cadence/
|
||
heart rate are on wildly different scales, so this is small multiples
|
||
rather than one chart with several y-axes) with a fixed `viewBox` width, stretched to
|
||
the container by CSS; mouse interaction converts `clientX` through the
|
||
container's current `getBoundingClientRect()` on every move rather
|
||
than fighting the SVG's own coordinate transform, so it stays correct
|
||
across container width without redrawing — though a browser resize
|
||
while the panel is open won't reflow the already-drawn paths until
|
||
it's reopened. One `pointermove` listener on the whole chart container
|
||
(not per-chart) finds the nearest profile point by distance and drives
|
||
three things at once: a synchronized crosshair line in every chart, a
|
||
single tooltip listing every active series' value at that point, and a
|
||
MapLibre `Marker` moved to that point's `lon`/`lat` on the map — so
|
||
hovering the chart shows where on the track that moment actually was.
|
||
Each chart's own min/max are shown as plain HTML overlays in its
|
||
top-left/bottom-left corners (not SVG `<text>` — the viewBox's
|
||
non-uniform scaling would stretch it). Below the stack, a single
|
||
shared x-axis strip (again HTML, same reason) shows 5 evenly-spaced
|
||
ticks along the shared distance scale; a Distance/Time toggle above it
|
||
(only shown when the track has any elapsed-time data at all) switches
|
||
what those 5 ticks are labeled with — the underlying point positions
|
||
stay distance-based either way, only the tick text changes, so
|
||
toggling is a cheap re-render of just that strip.
|
||
|
||
== Live GNSS position
|
||
|
||
A "GNSS" map control (`public/js/gnss.js`) shows the fix from a local
|
||
GNSS receiver (a real one, plugged into the machine running the
|
||
server — distinct from `NavigationControl`'s browser
|
||
`navigator.geolocation`, which the OS may or may not derive from such
|
||
a receiver at all).
|
||
|
||
`lib/maptool/gpsd_client.rb` is a small client for
|
||
https://gpsd.io[gpsd]'s JSON protocol over TCP (port 2947 by default):
|
||
it watches for TPV — "time-position-velocity", gpsd's term for a fix —
|
||
reports and yields each as a plain hash, reconnecting on any I/O error
|
||
so a receiver that isn't plugged in yet (or gpsd not running) is not
|
||
fatal. `app.rb` runs one of these in a background thread for the
|
||
whole process and keeps the latest fix in `GNSS_LATEST`, guarded by a
|
||
mutex since the HTTP threads read it concurrently.
|
||
|
||
`GET /api/gnss/position`, polled by the frontend every 1.5s, is a
|
||
deliberate choice over a push channel (SSE/WebSocket): gpsd itself
|
||
only reports at roughly 1Hz, so a push channel buys nothing here, and
|
||
it sidesteps a real problem — Sinatra's `stream(:keep_open)` helper,
|
||
absent an EventMachine reactor (i.e. under plain threaded Puma, as
|
||
here), calls its block in a tight loop instead of waiting for external
|
||
writes. That is fine for a stream that itself blocks between writes,
|
||
but for a "register once, get pushed to later" connection — which is
|
||
what a live position feed is — the block returns immediately and gets
|
||
re-invoked immediately, forever: every iteration re-registers the
|
||
connection, so held connections and memory grow unbounded. This was
|
||
caught in testing (the dev server hit ~98% CPU and ~28GB RSS within
|
||
minutes) before it shipped.
|
||
|
||
The endpoint reports `available: false` once the last fix is more than
|
||
`GNSS_STALE_AFTER` (10s) old, rather than serving a stale position
|
||
forever after the receiver loses its fix or gets unplugged.
|
||
|
||
MapLibre `Marker`s (used for the fix) are plain positioned DOM
|
||
elements, not tied to the style's sources/layers, so — unlike the
|
||
radar/GPX layers — the marker survives `map.setStyle()` (switching
|
||
Online/Offline) with no extra re-add handling needed.
|
||
|
||
== Networking
|
||
|
||
By default (local dev) the Sinatra server only binds to `127.0.0.1`
|
||
and isn't reachable from other machines. The deployed instance is an
|
||
exception — see `docs/deployment.adoc` for `BIND_HOST` and how it's
|
||
exposed via Docker/nginx as `map.priesol.net`.
|
||
|
||
== Authentication
|
||
|
||
The GPX store's read endpoints (viewing the map, the folder tree,
|
||
individual files) are public — anyone with the URL can look. Its write
|
||
endpoints (`POST /api/gpx`, `POST /api/gpx/folder`, `DELETE
|
||
/api/gpx/file/*`, `DELETE /api/gpx/folder/*`, `POST /api/gpx/move`)
|
||
require login, guarded by `require_login!` in `app.rb` — and so does
|
||
exporting a file (`GET /api/gpx/file/*?download=...`), even though
|
||
plain viewing of that same URL (no `?download`) doesn't; not a write
|
||
exactly, but treated the same as the others rather than left open.
|
||
The tree UI (`public/js/gpx.js`) mirrors this: every control for a
|
||
gated action (delete, download, upload, new folder) is left out of
|
||
the DOM entirely when logged out rather than shown and failing on
|
||
click — `isLoggedIn()` checks for the topbar's own "Log out" link
|
||
(`views/index.erb` only renders one when `logged_in`) instead of
|
||
threading a separate flag through, since that's already there and
|
||
login state can't change without a full page reload anyway. Delete
|
||
exists on both file and folder rows — both are gated, easy to miss
|
||
one and only catch it by testing an actually-logged-out session.
|
||
|
||
Login delegates to the Gitea instance at `git.priesol.net` via OAuth2
|
||
(`GET /login` redirects to Gitea's authorize endpoint, `GET
|
||
/auth/callback` exchanges the code and fetches
|
||
`/api/v1/user`) rather than maintaining a separate local
|
||
username/password — there's already an account to reuse, and it keeps
|
||
credential storage (password hash, resets, ...) out of this app
|
||
entirely. Successfully authenticating with Gitea is not by itself
|
||
enough to write, though — the callback also checks the returned
|
||
`login` against `MAPTOOL_ALLOWED_GITEA_USER` (a comma-separated
|
||
allowlist, e.g. `randy,friend1,friend2`), so registering (or already
|
||
having) any other account on that Gitea instance doesn't grant access
|
||
here. It's a flat allowlist — everyone in it has equal write access,
|
||
no per-user roles or permissions.
|
||
|
||
Session state is a signed cookie (`Rack::Session::Cookie`, `secure`
|
||
in production, `httponly`, `same_site: lax`) keyed off
|
||
`SESSION_SECRET` — see `docs/deployment.adoc` for where that and the
|
||
OAuth2 client id/secret live (not committed to the repo).
|