Upload GPX files into a folder hierarchy (traversal-safe path resolution against the store root), browse them in a tree panel, and toggle each one's waypoints/tracks on the map. Waypoints render as clickable points showing name, description and time; bare URLs in the description are turned into safe links. GPX layers survive switching between the Online/Offline base map, same as the radar overlay. Adds rexml as an explicit dependency — it moved to a bundled gem in this Ruby version and isn't picked up by Bundler otherwise.
167 lines
6.9 KiB
Plaintext
167 lines
6.9 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 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 and toggle them on the map, individually.
|
|
|
|
Storage:: Uploaded files are stored as-is under `data/gpx/`, in
|
|
whatever folder hierarchy the user types into the upload form (e.g.
|
|
`Hiking/2026`) — `Maptool::GpxStore`
|
|
(`lib/maptool/gpx_store.rb`) creates folders as needed. Every path
|
|
coming from the client (upload folder, 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/`.
|
|
`GET /api/gpx/tree` returns the resulting folder/file tree as JSON,
|
|
which the panel renders recursively (folders as `<details>`, files as
|
|
a checkbox).
|
|
|
|
Rendering:: Checking a file fetches `GET
|
|
/api/gpx/file/<path>` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses
|
|
the GPX with REXML and converts it to a GeoJSON `FeatureCollection`:
|
|
`<wpt>` waypoints become `Point` features (with `name`, `desc` and
|
|
`time` carried through as properties), each `<trk>` becomes one
|
|
`MultiLineString` feature (one line per `<trkseg>`). 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. `<rte>` 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 `<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`.
|
|
|
|
== Networking
|
|
|
|
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
|
|
reachable from other machines.
|