Sidebar toggle (new HillshadeControl) adds a raster-dem hillshade layer: public Terrarium tiles when online, or a local data/terrain.pmtiles when present (mirrors offline.pmtiles' optional- local-file pattern, new /api/terrain-tiles + /tiles/terrain.pmtiles endpoints). No offline terrain data is bundled, so the toggle is online-only until one is generated — see docs/development.adoc for how.
6.8 KiB
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). It is not part of the repository — obtain or
build one locally:
-
The simplest source is a pre-built extract from protomaps.com’s extract builder — pick a region and download it as
.pmtiles. Its schema is whatprotomaps-themes-base(the styling library used for the offline style) expects. -
To convert some other vector tile source (e.g. an
.mbtilesfile) into PMTiles, use the 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 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
rio-rgbify (GeoTIFF →
Terrarium-encoded PNG tiles in an .mbtiles), then
pmtiles convert out.mbtiles data/terrain.pmtiles with
go-pmtiles, same as
data/offline.pmtiles.
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.