Files
maptool/docs/architecture.adoc
T
randy 4e0d2abbd0 Add GPX upload with hierarchical folders and map display
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.
2026-08-25 14:05:39 +02:00

6.9 KiB
Raw Blame History

Architecture

Overview

MapTool is a Sinatra web application, served locally by Puma and opened in a browser.

Directory layout

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 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 a pmtiles:// 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.

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:

  1. 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).

  2. 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-opacity non-zero.

  3. 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.