Support uploading FIT files (and zipped exports) alongside GPX
CI / check (push) Successful in 2m11s
CI / deploy (push) Successful in 2m27s

Maptool::Fit reads Garmin's binary FIT format directly rather than
adding a gem — the one Ruby option depends on activesupport, unmaintained
since 2022, for a single-purpose binary parser. Only "record" messages
are read (position + altitude; everything else is skipped by byte
length, never interpreted); field numbers and scale/offset are
Garmin's own (fit-python-sdk's profile.py), and the framing plus all
extracted values were checked byte-for-byte against 5 real device
files from python-fitparse's test suite before this shipped.

A .zip upload (Garmin's bulk/FIT export format) is expanded into its
.gpx/.fit entries via rubyzip — that one's an actual dependency, since
ZIP's own format isn't the kind of narrow, single-purpose parsing FIT
above is.
This commit is contained in:
randy committed 2026-09-07 12:23:37 +02:00
1 parent 8d3c126a14
commit fc174d4267
8 files changed
+338 -29

No files matched your search

+51 -15
View File
@@ -31,10 +31,10 @@ opened in a browser.
| `data/`
| Local, gitignored data files, e.g. `offline.pmtiles` and uploaded
GPX files under `data/gpx/`.
GPX/FIT files under `data/gpx/`.
| `lib/maptool/`
| Application code beyond routing: the GPX parser and file store.
| Application code beyond routing: the GPX/FIT parsers and file store.
| `docs/`
| Project documentation, in AsciiDoc.
@@ -134,11 +134,12 @@ 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
== GPX/FIT tracks and waypoints
The "GPX" map control (`public/js/gpx.js`) opens a side panel to
upload GPX files by dragging them in, organize them into folders (also
by dragging), and toggle each one on the map individually.
The "Tracks" map control (`public/js/gpx.js`) opens a side panel to
upload GPX or FIT files by dragging them in, organize them into
folders (also by dragging), and toggle each one on the map
individually.
Storage:: Files are stored as-is under `data/gpx/`, in a
folder hierarchy the user manages directly — `Maptool::GpxStore`
@@ -155,8 +156,13 @@ which the panel renders recursively (folders as `<details>`, files as
a checkbox, both with a delete button).
Drag & drop:: The dropzone at the top of the panel accepts one or more
`.gpx` files (`POST /api/gpx`, multipart) into the top level, or click
it to browse instead. An already-uploaded file (a
`.gpx`/`.fit` files, or a `.zip` (`POST /api/gpx`, multipart) into the
top level, or click it to browse instead. A `.zip` — Garmin's bulk/FIT
export format — isn't stored itself: `save_zip_entries` (`app.rb`)
expands it with `rubyzip` and saves each `.gpx`/`.fit` entry it
contains individually (flattened — any folder structure inside the
zip is discarded), skipping anything else (readme files, etc.) it
might hold. An already-uploaded file (a
`.gpx-tree__file` row, `draggable`) can be dragged onto a folder to
move it there (`POST /api/gpx/move`), or dropped anywhere else in the
tree to move it back to the top level; an OS file can also be dropped
@@ -168,16 +174,46 @@ map) gets moved, its map layers are re-added under the new path so it
stays visible; if it (or its containing folder) gets deleted, its
layers are removed instead.
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
Rendering:: Checking a file fetches `GET /api/gpx/file/<path>`, routed
by extension to `Maptool::Gpx` or `Maptool::Fit`
(`lib/maptool/gpx.rb`/`fit.rb`) for parsing, and both convert to the
same GeoJSON `FeatureCollection` shape so the frontend doesn't need to
know which format it got. `Gpx` parses the GPX with REXML: `<wpt>`
waypoints become `Point` features (with `name`, `desc` and `time`
carried through as properties), each `<trk>` becomes one
`MultiLineString` feature (one line per `<trkseg>`); `<rte>` routes
are not handled. `Fit` reads Garmin's binary FIT format directly
(hand-rolled — see below) into a single `MultiLineString` `track`
feature; FIT activity files don't really have a GPX-waypoint
equivalent, so it never produces `Point` features. 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.
render both.
FIT parsing:: No gem was added for this — the one Ruby option
(`fit-parser`, tjwallace/fit) hasn't been touched since 2022 and pulls
in `activesupport` for a single-purpose parser, a heavy, stale
dependency for what this app actually needs. `Maptool::Fit` instead
reads only "record" messages (global message number 20 — every other
message type, laps/sessions/events/device info/developer fields, is
skipped by byte length by summing each field's declared size, never
interpreted) for 3 fields: `position_lat`/`position_long` (field 0/1,
`sint32` semicircles, converted to degrees by `* (180 / 2**31)`) and
`altitude`/`enhanced_altitude` (field 2/78, scale 5 / offset 500 —
`raw / 5.0 - 500`, in meters). Field numbers and scale/offset are
Garmin's own (github.com/garmin/fit-python-sdk's `profile.py`), not
guessed. Both the message framing (headers, definition messages,
compressed-timestamp headers, little/big-endian) and the 3 fields'
values were checked against `github.com/dtcooper/python-fitparse`'s
own test fixtures — 5 real device files spanning 2013-2019, one with
developer fields, one using only compressed-timestamp headers — byte
after byte, all coordinates and altitudes exact, before this shipped.
`.zip` extraction does use a gem (`rubyzip`) — parsing the ZIP central
directory format correctly (including compressed entries) isn't the
kind of narrowly-scoped, single-purpose logic FIT parsing above is,
and rubyzip is the de facto standard for this in Ruby.
Like the radar overlay, GPX layers are removed by `map.setStyle()`
(switching Online/Offline) and get re-added on `style.load` for