Files
maptool/docs/development.adoc
T
randy 9997a8a8c0
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 12s
Fix note UX: unreachable wiki link, white Save hover, stale icon preview, invisible new points
- "Nearby on Wikipedia" linked to KIWIX_URL, a Docker-internal hostname
  (http://kiwix:8080) no real browser can resolve. Split it: KIWIX_URL
  stays server-side-only for the reachability check, and the link
  itself is built from KIWIX_PUBLIC_PORT (kiwix-serve's newly
  published host port) plus whatever host the visitor already used to
  reach maptool — works regardless of which hostname/IP that is.
- The Save button went white on hover in GTK-themed Chrome: the
  browser's native button skin was painting over the custom
  background because appearance wasn't reset.
- The map preview marker for a pending note always showed a plain red
  pin no matter which icon was picked in the form. NoteSaveForm now
  reports icon changes via onIconChange so NotesControl can update the
  marker's glyph live.
- Saving a note (or a Measure "Save as note") left the new/grown
  .geojson file unchecked and invisible until manually toggled on.
  notifyGpxStoreChanged now carries the affected path so
  GpxPanelControl auto-shows it.
2026-09-09 15:11:50 +02:00

225 lines
9.6 KiB
Plaintext

= 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). It is not part of the repository — obtain or
build one locally:
* The simplest source is a pre-built extract from
https://app.protomaps.com[protomaps.com's extract builder] — pick a
region and download it as `.pmtiles`. Its schema is what
`protomaps-themes-base` (the styling library used for the offline
style) expects.
* To convert some other vector tile source (e.g. an `.mbtiles` file)
into PMTiles, use the
https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI:
`pmtiles convert in.mbtiles data/offline.pmtiles`.
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). It is not part of the
repository — 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`.
== 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`). A dev-time tool, not
something the running app calls itself — drop the output into
`data/gpx/` (or upload it through the Tracks panel) like any other
`.geojson` layer once it's generated. Nodes only (nothing that's
mapped as a way/relation, e.g. a supermarket traced as a building
outline rather than a single point).
== 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). This is
used only for the server-side reachability check — a browser can't
resolve a Docker-internal hostname like `kiwix`, so the link itself is
built from `KIWIX_PUBLIC_PORT` (the host port kiwix-serve is
`ports:`-published on, `8090` in `docker-compose.yaml`) plus whatever
host the visitor already used to reach maptool itself. 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. kiwix-serve's own geo search
(`GET /search?pattern=&latitude=&longitude=&distance=`,
`lib/maptool/kiwix.rb`'s docstring) is linked to directly, not
proxied/embedded — it already has a full search UI of its own.
== 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.