Files
maptool/docs/architecture.adoc
T
randy 215957382a
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 16s
Group Home/zoom/base-map into one top-left stack, NPS-style
Home was alone at top-left and zoom (NavigationControl) was down at
bottom-right with GeolocateControl — split across two corners instead of
grouped together like the National Park Service's own map controls
(https://www.nps.gov/, screenshot: Home, zoom in/out, base map, all
stacked in one place).

Moved NavigationControl up to join Home (showCompass: false — this map
never rotates, so a bearing-reset button would be dead weight), and added
MapLayersButton, a small map-corner control that's a second entry point
into the same Online/Offline panel LayerToggleControl already manages in
the page's sidebar (both go through the same toggle() now, so there's one
source of truth for whether the panel's open). GeolocateControl stays
alone at bottom-right — it's a different action (go to my actual location)
from Home (reset to the app's default view), which was worth clarifying
since the two are easy to conflate.
2026-09-07 20:23:37 +02:00

30 KiB
Raw Blame History

Architecture

Overview

MapTool is a Sinatra web application, served locally by Puma and opened in a browser.

Directory layout

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 MapLibre GL JS (vendored, see below) and the map view (public/js/map.js) offers two styles:

  • Online — a full hosted vector style, OpenFreeMap "liberty", requires internet access.

  • Offline — built at runtime from a local PMTiles archive at data/offline.pmtiles, served as-is (see below) at /tiles/offline.pmtiles, decoded client-side by pmtiles.js registered as a pmtiles:// protocol handler, and styled with base layers from 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 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 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, pinned to CENTER/ZOOM explicitly rather than its own fallback (the view at construction time, which would just be wherever a loaded permalink happened to point). It resets to the app’s default view, not the visitor’s own location — that’s GeolocateControl, a separate button at bottom-right.

That Home button is the top of a small top-left stack — Home, zoom in/out (NavigationControl, showCompass: false since this map never rotates), and a "Base map" button — matching the National Park Service’s own map control layout (grouped together on the map itself, rather than split 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.

Weather radar overlay

public/js/radar.js adds a "Radar" map control that overlays recent precipitation radar from RainViewer's free public API (no API key required). Enabling it:

  1. 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).

  2. Takes the most recent 7 (last ~60 minutes — the same default as the 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.

  3. 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 tracks and waypoints

The "Tracks" map control (public/js/gpx.js) opens a side panel to upload GPX or FIT 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 files, or a .zip (POST /api/gpx, multipart) into the top level, or click it to browse instead. 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 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. 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), 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.

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 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.

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).