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.
12 KiB
Architecture
Overview
MapTool is a Sinatra web application, served locally by Puma and opened in a browser.
Directory layout
| Path | Purpose |
|---|---|
|
Sinatra application and Rack entry point. |
|
Puma server configuration (binds to |
|
Frontend assets and Sinatra templates. The map view uses MapLibre GL JS, rendering vector tiles. |
|
Third-party frontend libraries, vendored (not loaded from a CDN) so
the app also works with no internet access. See
|
|
Local, gitignored data files, e.g. |
|
Application code beyond routing: the GPX parser and file store. |
|
Project documentation, in AsciiDoc. |
Offline maps
Map data is vector, not raster: the app ships
MapLibre GL JS (vendored, see below) and the map
view (public/js/map.js) offers two styles:
-
Online — a full hosted vector style, OpenFreeMap "liberty", requires internet access.
-
Offline — built at runtime from a local PMTiles archive at
data/offline.pmtiles, served as-is (see below) at/tiles/offline.pmtiles, decoded client-side by pmtiles.js registered as apmtiles://protocol handler, and styled with base layers from 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 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 patchespaintcolors 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 insideinterpolate/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, sosymbollayers (all labels/icons) are never in scope. Result is cached after the first fetch. -
Offline:
offlineTheme()overrides the relevant color roles on aprotomaps-themes-baseThemeobject (starting fromnamedTheme("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 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 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-opacitynon-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) providessave,create_folder,delete_file,delete_folderandmove. Every path coming from the client (a folder to create/upload/move into, a file path in a URL) is resolved withFile.expand_pathand checked against the store root before touching the filesystem, so../..-style path traversal can’t escapedata/gpx/;delete_folderadditionally refuses an empty path or one that resolves to the store root itself, so it can neverrm -rfthe whole store.GET /api/gpx/treereturns 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
.gpxfiles (POST /api/gpx, multipart) into the top level, or click it to browse instead. An already-uploaded file (a.gpx-tree__filerow,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 customapplication/x-gpx-pathdrag 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 GeoJSONFeatureCollection:<wpt>waypoints becomePointfeatures (withname,descandtimecarried through as properties), each<trk>becomes oneMultiLineStringfeature (one line per<trkseg>). MapLibre’sgeometry-typeexpression treatsMultiLineStringthe same asLineString, so a single GeoJSON source per file with alinelayer and acirclelayer (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 survivessetStyle()) rather than on everystyle.loadre-add, which would otherwise stack duplicate handlers and pop up several copies per click. Waypoint text comes straight from an uploaded file — untrusted input — sopublic/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 bothhrefand link text) before assembling any HTML; the URL pattern excludes quote characters, so a matched URL can never break out of thehref="…"attribute. Every other value is HTML-escaped before being handed toPopup#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
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.