= 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=//` 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 `GeolocateControl`, 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 (`GeolocateControl`), 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 ` (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. === 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 `
`, 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/`, 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 (`.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/?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 `` (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 `` to trigger the actual save. `Gpx` parses the GPX with REXML: `` waypoints become `Point` features (with `name`, `desc` and `time` carried through as properties), each `` becomes one `MultiLineString` feature (one line per ``), and each `` becomes one too (a route is a single continuous line by definition, no ``-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`). `` has no GPX equivalent of ``'s ``, 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 (``) and, in a vendor `` 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 `` alongside ``, 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 `` → `` → `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-` 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 `` 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 `` 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 `` 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 `` per listing photo (not a vendor extension like `waypoint_style`'s `` — `` is base GPX 1.1, any number allowed per ``/``/``). `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 `