Add activity type and a click-to-open track profile panel
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 15s

Track properties gain activity_type (FIT: sport enum from the
session/sport message, Garmin's own table; GPX: <trk><type> passed
through as-is) alongside the existing distance/elevation/duration.

Clicking a track line now opens a bottom panel with a chart per
metric the track actually has (elevation always; speed/cadence/heart
rate only when present — FIT's record fields or GPX's device
extensions). TrackStats.build_profile computes cumulative distance
the same way for both formats so they're directly comparable. One
pointermove listener drives a synchronized crosshair across every
chart, a single tooltip listing all series, and a map marker at the
hovered point's position — hovering the chart shows where on the
track that moment was.
This commit is contained in:
randy committed 2026-09-07 13:38:49 +02:00
1 parent cd4eef342b
commit 105651f005
14 files changed
+655 -42

No files matched your search

+54 -15
View File
@@ -234,21 +234,60 @@ matched URL can never break out of the `href="..."` attribute. Every
other value is HTML-escaped before being handed to `Popup#setHTML`.
Track properties:: Every track feature (both formats) also carries
`distance_meters`, `elevation_gain_meters`, `duration_secs` and
`point_count`, computed once server-side by `Maptool::TrackStats`
(`lib/maptool/track_stats.rb`, shared by `Gpx`/`Fit` so neither
duplicates the haversine formula) rather than in the browser — cheap
either way, but it means a GPX and a FIT file end up with the same
properties shape, so the frontend doesn't need to know which format it
got. Distance sums the haversine distance between consecutive points;
elevation gain sums only the positive deltas (descents don't
subtract); duration is the gap between the first and last known
timestamp, skipping unknown ones in between — `nil` for any of these
where the source data doesn't have enough to compute it (e.g. a GPX
file with no `<time>` elements has `duration_secs: nil`). Hovering a
track line (rather than clicking, unlike a waypoint — these properties
describe the whole line, not whichever point the cursor happens to be
over) shows them in the same popup mechanism.
`distance_meters`, `elevation_gain_meters`, `duration_secs`,
`point_count` and `activity_type`, computed once server-side by
`Maptool::TrackStats` (`lib/maptool/track_stats.rb`, shared by
`Gpx`/`Fit` so neither duplicates the haversine formula) rather than
in the browser — cheap either way, but it means a GPX and a FIT file
end up with the same properties shape, so the frontend doesn't need to
know which format it got. Distance sums the haversine distance between
consecutive points; elevation gain sums only the positive deltas
(descents don't subtract); duration is the gap between the first and
last known timestamp, skipping unknown ones in between — `nil` for any
of these where the source data doesn't have enough to compute it (e.g.
a GPX file with no `<time>` elements has `duration_secs: nil`).
`activity_type` comes from wherever each format keeps it: FIT's
`session`/`sport` message has a `sport` enum field (`Fit::SPORT_NAMES`
— Garmin's own table, github.com/garmin/fit-python-sdk's `profile.py`,
not exhaustive of every value Garmin has defined but every mainstream
one); GPX's `<trk><type>` is free text, whatever a device/app chose to
write, passed through as-is. Hovering a track line (rather than
clicking, unlike a waypoint — these properties describe the whole
line, not whichever point the cursor happens to be over) shows them in
the same popup mechanism as waypoints; the popup also hints that
clicking opens the full profile panel below.
Track profile panel:: Clicking a track line opens a bottom panel
(`public/js/track_profile.js`, `TrackProfilePanel`) with a chart per
metric the track actually has data for — elevation always (assuming
any point carries an `<ele>`/altitude), speed/cadence/heart rate only
when present. `Maptool::TrackStats.build_profile` (called from both
`Gpx`/`Fit`, alongside `.compute` above) produces this: one entry per
point with cumulative distance (computed the same way for every
format, not trusted from a device's own odometer field, so a GPX and a
FIT track are directly comparable), elevation, and whatever
speed/cadence/heart-rate extras that point's format/file carried —
`nil` fields simply don't render their chart. For FIT, those extras
are the `record` message's own `heart_rate`/`cadence`/`speed` fields
(3/4/6); for GPX, a device-specific `<extensions>` block under each
`<trkpt>` — usually Garmin's `TrackPointExtension`, matched by local
element name only (`hr`/`cad`/`speed`) so it doesn't care which XML
namespace prefix a given file happens to declare.
+
Each chart is its own single-series `<svg>` (elevation/speed/cadence/
heart rate are on wildly different scales, so this is small multiples
rather than one chart with several y-axes) with a fixed `viewBox` width, stretched to
the container by CSS; mouse interaction converts `clientX` through the
container's current `getBoundingClientRect()` on every move rather
than fighting the SVG's own coordinate transform, so it stays correct
across container width without redrawing — though a browser resize
while the panel is open won't reflow the already-drawn paths until
it's reopened. One `pointermove` listener on the whole chart container
(not per-chart) finds the nearest profile point by distance and drives
three things at once: a synchronized crosshair line in every chart, a
single tooltip listing every active series' value at that point, and a
MapLibre `Marker` moved to that point's `lon`/`lat` on the map — so
hovering the chart shows where on the track that moment actually was.
== Live GNSS position