= Development guide :toc: :toclevels: 2 == Web app only [source,shell] ---- 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: [source,shell] ---- 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 (https://build-metadata.protomaps.dev/builds.json[build index], files at `https://build.protomaps.com/`, ~120GB, ODbL/OSM attribution) with the https://github.com/protomaps/go-pmtiles[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/ 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 https://github.com/tilezen/joerd[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 — build one locally from a DEM source (e.g. Copernicus GLO-30 or SRTM) with a tool like https://github.com/mapbox/rio-rgbify[rio-rgbify] (GeoTIFF -> Terrarium-encoded PNG tiles in an `.mbtiles`), then `pmtiles convert out.mbtiles data/terrain.pmtiles` with https://github.com/protomaps/go-pmtiles[go-pmtiles], same as `data/offline.pmtiles`. == 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 https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI installed and on `PATH` (override with the `PMTILES_BIN` env var if it lives somewhere else); regenerating terrain.pmtiles this way fetches individual Terrarium tiles itself and packs them into an MBTiles file (the `sqlite3` gem) before the same `pmtiles convert` step, since no public Terrarium-encoded PMTiles archive exists to `extract` from the way offline.pmtiles above can. 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 overpass:pois[south,west,north,east,out.geojson]` queries https://overpass-api.de[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 https://github.com/kiwix/kiwix-tools[kiwix-serve] running with a Wikipedia https://library.kiwix.org[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: * https://library.kiwix.org/#lang=eng&category=wikipedia[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 https://gpsd.io[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: [source,shell] ---- 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: [source,shell] ---- # 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 https://docs.renovatebot.com[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.