Files
maptool/docs/architecture.adoc
T
randy 87df90f75c Redesign UI as an OsmAnd-style sidebar, add live GNSS position via gpsd
Restructure the map controls from MapLibre corner buttons into a left
sidebar of icons with flyouts/panels (radar, GPX, GNSS), matching
OsmAnd's layout more closely:

- Base map and GPX selection are now full-height side panels docked
  next to the sidebar (not small dropdown flyouts or a right-docked
  panel), opening/closing exclusively via a small shared event bus
  (panels.js) so only one is ever open at a time.
- The GPX panel's upload/new-folder controls are now icon buttons in
  the header instead of an always-visible dropzone and form; dragging
  a GPX file onto the tree, a specific folder, or straight onto the
  map itself all upload it.
- New: Maptool::GpsdClient (lib/maptool/gpsd_client.rb) streams live
  GNSS fixes from a local gpsd daemon over Server-Sent Events
  (/api/gnss/stream) to a marker on the map (public/js/gnss.js) — one
  background gpsd connection shared by every browser tab.
- The online map style (public/js/osmand-style.js) is now styled to
  resemble OsmAnd's own rendering, applied to both the online and
  offline (PMTiles) styles.

Also adds a GeolocateControl (browser geolocation) next to the zoom
controls.
2026-08-25 19:39:13 +02:00

264 lines
12 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.
=== Visual style
Both styles are recolored, by `public/js/osmand-style.js`, to
approximate the look of https://osmand.net/map[OsmAnd's own web map]
(cream background, green forests, orange roads, purple admin
boundaries) — deliberately *not* by using OsmAnd's tile server
directly: its terms restrict tile use to "personal, non-commercial use
within OsmAnd applications and platforms", which this app isn't.
One shared color palette drives two independent recolorings:
* *Online*: `onlineStyle()` fetches OpenFreeMap's raw "liberty" style
JSON and patches `paint` colors on its layers in place — matching a
layer to a category (water, wood, park, landuse, building, major/
minor road, boundary) by its id, then walking that layer's color
properties (`background-color`, `fill-color`, `line-color`,
`fill-extrusion-color`, and any `*-outline-color`) and replacing
every color-shaped leaf — including inside `interpolate`/other
expressions — with the category's palette color, leaving non-color
leaves (operators, zoom stops, `["get", ...]`) untouched. Labels and
railways are left at their original colors — only categorized layer
types (`background`/`fill`/`line`/`fill-extrusion`) are touched at
all, so `symbol` layers (all labels/icons) are never in scope.
Result is cached after the first fetch.
* *Offline*: `offlineTheme()` overrides the relevant color roles on a
`protomaps-themes-base` `Theme` object (starting from
`namedTheme("light")`), leaving roles it doesn't set (labels — moot,
see above — rail, tunnels, POI colors) at their defaults.
=== Known limitation: coarser road colors offline
protomaps-themes-base's `Theme` only has two paint-relevant road tiers
(`major` / `minor_a`+`minor_b`, plus `link` for ramps) versus
OpenFreeMap's finer-grained classes (`road_motorway`,
`road_trunk_primary`, `road_secondary_tertiary`, `road_minor`, ...).
Mapping `major` to the palette's road-major orange colors more roads
orange offline than online (protomaps' "major" bucket is broader than
just motorway/trunk/primary) — a real difference in how coarse the two
underlying style systems are, not a bug in the recoloring itself.
== 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 `<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` 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/<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`.
== 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
https://gpsd.io[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
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
reachable from other machines.