Files
maptool/docs/development.adoc
T
randy f89dea5805
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 1m52s
Build terrain.pmtiles from Mapterhorn instead of fetching tiles ourselves
Mapterhorn (mapterhorn.com, Protomaps-affiliated) publishes pre-built
Terrarium-encoded terrain as PMTiles: a global planet.pmtiles (z0-12)
plus ~450 regional chunks (z13+). Same pmtiles extract --bbox=...
mechanism offline.pmtiles already uses, just against their files —
confirmed live against their real manifest and the documented
Interlaken example. Replaces fetching every individual tile ourselves
(even after this session's earlier concurrency fix, still tens of
minutes for a real region) with 1-3 extract calls plus a merge when
the bbox spans more than one chunk: seconds, not minutes.

Removes CONCURRENT_TILE_FETCHES, write_terrarium_mbtiles, tile_range,
lng/lat_to_tile_x/y, fetch_terrarium_tile, and the sqlite3 gem
entirely — none of it is needed once the tiles arrive pre-packaged.

dem.js's point-elevation lookup (shadow.js's terrain-shadow overlay)
hardcoded a 256px PNG tile assumption that Mapterhorn's 512px WebP
tiles would have silently broken — now reads the actually-decoded
tile's own size and mime type instead of assuming either.
2026-09-11 12:26:03 +02:00

14 KiB
Raw Blame History

Development guide

Web app only

bundle install
bundle exec puma -C config/puma.rb

Open http://127.0.0.1:4567. For auto-reload during development, run via the rerun gem instead:

bundle exec rerun -- bundle exec puma -C config/puma.rb
Tip
Browsers apply heuristic caching to static files even without explicit cache headers, so an edit to public/ (or views/) can appear not to have taken effect on the next reload. Force a real reload (e.g. Ctrl/Cmd+Shift+R) if that happens.

Offline map data

To enable the offline style, place a PMTiles file at data/offline.pmtiles (see docs/architecture.adoc for how it is served and styled). Logged-in users can generate this from the app itself — see "In-app regeneration" below — or it can be built by hand:

  • Extract a region from Protomaps' own daily full-planet basemap build (build index, files at https://build.protomaps.com/<key>;, ~120GB, ODbL/OSM attribution) with the go-pmtiles CLI’s extract subcommand, which pulls only the byte ranges covering a bbox over HTTP range requests rather than the whole file: pmtiles extract https://build.protomaps.com/<key>; data/offline.pmtiles --bbox=west,south,east,north --maxzoom=15. (The old app.protomaps.com extract-builder UI this section used to point at has since redirected elsewhere — this is the same underlying mechanism, just the CLI instead of a web UI.)

  • To convert some other vector tile source (e.g. an .mbtiles file) into PMTiles instead, use the same CLI’s convert subcommand: pmtiles convert in.mbtiles data/offline.pmtiles. Its schema is what protomaps-themes-base (the styling library used for the offline style) expects.

Once the file is in place, GET /api/offline-tiles reports available: true and the map defaults to the offline style.

Offline terrain data (hillshade)

The sidebar’s "Terrain shading" toggle works online out of the box (public Terrarium elevation tiles, no API key). For it to work offline too, place a PMTiles archive of Terrarium-encoded raster-dem tiles at data/terrain.pmtiles (GET /api/terrain-tiles reports available: true once it’s there, same pattern as offline map data above). Also generatable in-app (see below) or by hand, the same go-pmtiles extract mechanism as data/offline.pmtiles above — but from Mapterhorn, a Protomaps-affiliated project publishing pre-built Terrarium-encoded terrain as PMTiles (a global planet.pmtiles, z0-12, plus ~450 regional chunks covering z13 and up — see their own data-access page for the full manifest and per-source license/attribution details, CC-BY-4.0 and similar, varying by region):

pmtiles extract https://download.mapterhorn.com/planet.pmtiles planet-extract.pmtiles --bbox=west,south,east,north --maxzoom=15
# only if a regional chunk covers the bbox too (see download_urls.json) —
# most bboxes hit exactly one, a bbox straddling a chunk boundary hits two:
pmtiles extract https://download.mapterhorn.com/<chunk>.pmtiles chunk-extract.pmtiles --bbox=west,south,east,north --maxzoom=15
pmtiles merge planet-extract.pmtiles chunk-extract.pmtiles data/terrain.pmtiles

Tiles are 512px WebP, not the 256px PNG this app’s online Terrarium source uses — `public/js/dem.js’s own point-elevation lookups (`shadow.js’s terrain-shadow overlay) read the actual decoded tile size rather than assuming one, so this isn’t a problem either way.

In-app regeneration

A logged-in user can also generate both files above from the app itself, no local tooling needed — the sidebar’s "Offline data" icon (Offline hub group) shows each file’s status (size, last updated) and a "Regenerate for current view" button. The region is always the current map viewport, same "no area-picker, the visible area is the area" convention the Overpass POI plugin already uses below. This wraps exactly the manual go-pmtiles workflows documented above (lib/maptool/pmtiles_builder.rb) — the server needs the go-pmtiles CLI installed and on PATH (override with the PMTILES_BIN env var if it lives somewhere else). Fetches Mapterhorn’s own small JSON manifest to work out which regional chunk(s), if any, intersect the bbox, extract`s each (plus `planet.pmtiles, always), then `merge`s them if there was more than one — typically seconds, not the tens of minutes an earlier version of this (fetching every individual Terrarium tile itself) used to take for a real-sized region.

A build runs in the background (POST /api/offline-tiles/build / /api/terrain-tiles/build, polled via the matching */build-status route) — only one of each kind at a time; starting a second while one’s running gets a 409.

Camper-relevant POIs (Overpass)

rake oversouth,west,north,east,out.geojson queries Overpass for drinking water, dump stations, LPG refill, public toilets, laundromats, supermarkets/convenience stores, and springs within a bbox, and writes the result as a .geojson file (lib/maptool/overpass_pois.rb). Nodes only (nothing that’s mapped as a way/relation, e.g. a supermarket traced as a building outline rather than a single point). Also reachable from inside the running app itself, without the command line — see Import plugins below, which wraps this exact query as plugins/overpass-pois.

Import plugins

The sidebar’s import icon (logged in only) lists whatever’s under plugins/ and runs one for the current map viewport with a click — no area-picker to fill in, the visible area is the area. Each plugin is a directory with a plugin.json manifest ({"label": "…​", "command": "./run.sh"}) plus whatever script that command names; the entire interface a plugin needs to know about maptool is "read --south/--west/--north/--east, print a GeoJSON FeatureCollection to stdout" (lib/maptool/plugins.rb) — the result is saved as a new .geojson file the same way an upload would be.

plugins/overpass-pois ships as the reference example (a thin wrapper around the rake overpass:pois logic above). A plugin that scrapes a site whose terms of service forbid that — Park4Night, iOverlander, Freecampsites — doesn’t have to live in this (public) repo to work here: drop its directory under plugins/ locally and just don’t git add it, same as any other local-only file this app already treats that way (data/offline.pmtiles, the kiwix ZIM, …​).

Offline Wikipedia (kiwix-serve)

A point popup’s "Nearby on Wikipedia" link needs kiwix-serve running with a Wikipedia ZIM loaded — set KIWIX_URL to wherever the server can reach it (docker-compose.yaml already runs a kiwix service pointed at data/kiwix/wikipedia.zim and wires KIWIX_URL to it via its compose service name; blank/unset disables the link, same as every other optional integration here). Used only server-side: production sits behind a reverse proxy that forwards only to this app, so there’s no address a browser could reach kiwix-serve at directly — GET /kiwix/* (app.rb) proxies to it instead, and the kiwix service runs with --urlRootLocation=/kiwix so its own emitted links/assets already carry that same prefix. Not part of the repo — a ZIM is a large, self-contained archive, obtain one locally:

  • Kiwix’s own library has pre-built Wikipedia ZIMs — a _maxi variant per language/region includes images, _nopic is much smaller if browsing images offline doesn’t matter here.

  • Download it to data/kiwix/wikipedia.zim (the exact filename docker-compose.yaml’s `kiwix service expects — rename after downloading, Kiwix’s own filenames are much more specific). A symlink there works too (ln -s /wherever/the-real-file.zim data/kiwix/wikipedia.zim), so the real file doesn’t have to sit inside this repo’s checkout at all — the compose file’s volume binds that exact path, not the data/kiwix/ directory, specifically so this resolves correctly (a symlink inside an already-mounted directory wouldn’t: the container would have to resolve it itself, in its own filesystem, where the host path it points to doesn’t exist).

Once it’s there, docker compose up -d kiwix (or a full restart) picks it up — GET /api/kiwix reports available: true and the "Nearby on Wikipedia" link appears, pointed at kiwix-serve’s own geo search (GET /kiwix/search?pattern=&latitude=&longitude=&distance=) through this app’s own proxy rather than at kiwix-serve directly — it already has a full search UI of its own, this just makes it reachable through the same origin/reverse proxy as the rest of the app.

GNSS position (gpsd)

The "GNSS" control needs gpsd running and pointed at a receiver (gpsd /dev/ttyUSB0, or whatever device the receiver shows up as). On openSUSE, gpsd is a package with systemd units (gpsd.service / gpsd.socket); on other distributions, check your package manager. No app-side configuration is needed — it just connects to 127.0.0.1:2947, gpsd’s default port.

To test without hardware or gpsd itself installed/running, run bin/gnss_simulator.rb — it speaks gpsd’s own JSON wire protocol directly on gpsd’s default port (127.0.0.1:2947), so Maptool::GpsdClient connects to it exactly as it would to real gpsd, no app-side changes needed. Drives a loop around a center point (Bratislava by default) at a driving-ish pace instead of sitting at a fixed position, since a stationary "GNSS position" isn’t much to look at on the map:

bin/gnss_simulator.rb                       # loop around Bratislava, 300m radius
bin/gnss_simulator.rb 48.85 2.35 500         # custom center/radius (Paris, 500m)

GET /api/gnss/position should report a fix within a couple of seconds, moving on every poll.

To test against gpsd itself instead (e.g. to exercise the actual NMEA-parsing path, not just gpsd’s JSON output), feed it a fake NMEA stream over TCP instead of a real serial device — gpsd accepts a tcp://host:port source:

# terminal 1: a TCP server repeating one NMEA GGA/RMC sentence pair
python3 - <<'PY' 5001
import socket, sys, time

def checksum(s):
    cs = 0
    for ch in s:
        cs ^= ord(ch)
    return f"{cs:02X}"

def sentence(body):
    return f"${body}*{checksum(body)}\r\n"

port = int(sys.argv[1])
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", port))
srv.listen(1)
conn, _ = srv.accept()
gga = "GPGGA,123519,4808.916,N,01706.462,E,1,08,0.9,140.4,M,46.9,M,,"
rmc = "GPRMC,123519,A,4808.916,N,01706.462,E,000.5,084.4,230824,003.1,W"
while True:
    conn.sendall((sentence(gga) + sentence(rmc)).encode())
    time.sleep(1)
PY

# terminal 2: point gpsd at it instead of a real device
gpsd -N -n tcp://127.0.0.1:5001

GET /api/gnss/position should then report the fake fix (Bratislava Castle, in the sentences above) within a couple of seconds.

Tests

bundle exec rake test runs the whole suite (test/test_*.rb, Minitest — a default gem, but pinned in the Gemfile’s `:test group for reproducibility, same reasoning as rake itself). test_app.rb drives app.rb’s routes end to end through `Rack::MockRequest rather than a real server; everything else tests one lib/maptool/ module in isolation. MAPTOOL_DATA_DIR (see app.rb) is pointed at a throwaway Dir.mktmpdir by test/test_helper.rb before app.rb loads, so running the suite never touches real data/ — the app-level tests also wipe that directory between each test (see AppTestHelper#teardown), since GPX_STORE/LIVETRACK_STORE are process-wide constants shared by every test in the run.

LiveTrackClient’s own tests use a synthetic fixture (no network) — its parsing was checked against real Garmin sessions by hand during development, not by the suite, since that needs a live, unexpired session token to fetch. `Fit’s tests likewise build a minimal FIT file by hand rather than shipping a real device fixture; correctness against real files (position/altitude fields, definition-message framing, compressed-timestamp headers) was checked by hand against `python-fitparse’s test fixtures during development — see `docs/architecture.adoc.

Continuous integration

.gitea/workflows/ci.yml runs on every push/PR against a self-hosted Gitea Actions runner: ruby -c on every .rb file, a require_relative "app" smoke check that the app loads without error, bundle exec rake test (see above), and node --check on every file under public/js/ — no JS test suite, just a syntax check.

.gitea/workflows/renovate.yml runs Renovate (the renovate/renovate image) weekly (Mondays, also triggerable manually), opening PRs for outdated dependencies (Bundler Gemfile/Gemfile.lock, auto-detected — no renovate.json needed) against this Gitea instance (RENOVATE_PLATFORM=gitea). It runs on the native deploy runner (see docs/deployment.adoc) as a one-shot docker run, not a persistent service. Needs a RENOVATE_TOKEN repo secret — a Gitea personal access token (Settings → Applications → Generate New Token, with repository read/write scope) from whichever account should author the PRs; RENOVATE_ONBOARDING=false skips Renovate’s usual first-PR "configure me" step and goes straight to dependency update PRs using its defaults.