= 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 — otherwise it defaults to the online style. Either way, the layer toggle in the top-left of the map lets the user switch manually. 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` also nudges a handful of color roles (background, water, wood, buildings, the two road-importance tiers) from `protomaps-themes-base`'s stock "light" theme towards the online "liberty" style's actual palette (sampled from its style JSON), so Online/Offline don't look like two different apps. This is deliberately partial — most roles (labels, casings, minor terrain fills, ...) are left at their 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"). 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. == 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 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 `
`, 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/`, 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: `` waypoints become `Point` features (with `name`, `desc` and `time` carried through as properties), each `` becomes one `MultiLineString` feature (one line per ``); `` routes are not handled. `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. MapLibre's `geometry-type` expression treats `MultiLineString` the same as `LineString`, so a single GeoJSON source per file with a `line` layer and a `circle` layer (each filtered by geometry type) is enough to render both. 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 circle opens a MapLibre popup with its name, 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`. Track properties:: Every track feature (both formats) also carries `distance_meters`, `elevation_gain_meters`, `duration_secs` and `point_count`, 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 `