Uploading is now drag & drop (or click to browse) instead of a form; folders can be created any time, not just during upload; files can be dragged onto a folder to move them, or dropped elsewhere in the tree to move back to the top level; files and folders can be deleted. Both drag sources (an OS file drag and dragging an existing tree entry) land on the same drop targets, told apart by a custom drag payload type versus the browser's own "Files" type. Also sort folders before files in the tree (previously interleaved alphabetically).
184 lines
8.0 KiB
Plaintext
184 lines
8.0 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 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`.
|
|
|
|
== Networking
|
|
|
|
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
|
|
reachable from other machines.
|