= 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 files under `data/gpx/`. | `lib/maptool/` | Application code beyond routing: the GPX parser 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. === Known limitation: no offline text labels The offline style only renders `protomaps-themes-base`'s non-label layers (roads, buildings, land use, water — no place names or street labels). Label rendering in MapLibre needs a "glyphs" server serving font PBF ranges, which would mean vendoring a font glyph set locally too; that has not been done, to keep the initial scope manageable. Revisit this if labelled offline maps turn out to matter in practice. 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 tracks and waypoints The "GPX" map control (`public/js/gpx.js`) opens a side panel to upload GPX 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` files (`POST /api/gpx`, multipart) into the top level, or click it to browse instead. 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/` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses the GPX with REXML and converts it to a GeoJSON `FeatureCollection`: `` waypoints become `Point` features (with `name`, `desc` and `time` carried through as properties), each `` becomes one `MultiLineString` feature (one line per ``). 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 waypoints and tracks. `` routes are not handled. 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`. == Networking The Sinatra server only binds to `127.0.0.1`. It is not meant to be reachable from other machines.