Support uploading FIT files (and zipped exports) alongside GPX
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:
1 parent
8d3c126a14
commit
fc174d4267
8 files changed
+338
-29
No files matched your search
+51
-15
@@ -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
|
||||
|
||||
Reference in new issue
Block a user