Add activity type and a click-to-open track profile panel
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:
1 parent
cd4eef342b
commit
105651f005
14 files changed
+655
-42
No files matched your search
Vendored
+13
@@ -8,14 +8,27 @@
|
||||
</wpt>
|
||||
<trk>
|
||||
<name>Morning Loop</name>
|
||||
<type>running</type>
|
||||
<trkseg>
|
||||
<trkpt lat="48.1" lon="17.2">
|
||||
<ele>200</ele>
|
||||
<time>2026-01-01T09:00:00Z</time>
|
||||
<extensions>
|
||||
<gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
|
||||
<gpxtpx:hr>140</gpxtpx:hr>
|
||||
<gpxtpx:cad>80</gpxtpx:cad>
|
||||
</gpxtpx:TrackPointExtension>
|
||||
</extensions>
|
||||
</trkpt>
|
||||
<trkpt lat="48.101" lon="17.201">
|
||||
<ele>250</ele>
|
||||
<time>2026-01-01T09:10:00Z</time>
|
||||
<extensions>
|
||||
<gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
|
||||
<gpxtpx:hr>155</gpxtpx:hr>
|
||||
<gpxtpx:cad>84</gpxtpx:cad>
|
||||
</gpxtpx:TrackPointExtension>
|
||||
</extensions>
|
||||
</trkpt>
|
||||
<trkpt lat="48.102" lon="17.202">
|
||||
<ele>230</ele>
|
||||
|
||||
@@ -57,4 +57,68 @@ class FitTest < Minitest::Test
|
||||
|
||||
assert_raises(Maptool::Fit::ParseError) { Maptool::Fit.to_geojson(path) }
|
||||
end
|
||||
|
||||
# A second, richer fixture (session message with a sport enum, record
|
||||
# messages carrying heart_rate/cadence/speed too) — kept separate from
|
||||
# build_fit/write_fit above so those stay a minimal, easy-to-read
|
||||
# position-only case.
|
||||
def build_activity_fit(records, sport: nil)
|
||||
body = +""
|
||||
|
||||
if sport
|
||||
session_definition = [0, 0].pack("CC") + [18].pack("S<") + [1].pack("C") + [5, 1, 0x00].pack("C3")
|
||||
body << [0b01000001].pack("C") << session_definition
|
||||
body << [0b00000001].pack("C") << [sport].pack("C")
|
||||
end
|
||||
|
||||
record_definition = [0, 0].pack("CC") + [20].pack("S<") + [5].pack("C") +
|
||||
[0, 4, 0x85].pack("C3") + [1, 4, 0x85].pack("C3") +
|
||||
[3, 1, 0x02].pack("C3") + [4, 1, 0x02].pack("C3") + [6, 2, 0x84].pack("C3")
|
||||
body << [0b01000000].pack("C") << record_definition
|
||||
|
||||
records.each do |r|
|
||||
body << [0b00000000].pack("C") << le32(r[:lat]) << le32(r[:lon])
|
||||
body << [r[:heart_rate] || 0xFF].pack("C") << [r[:cadence] || 0xFF].pack("C")
|
||||
speed_raw = r[:speed_mps] ? (r[:speed_mps] * 1000).round : 0xFFFF
|
||||
body << [speed_raw].pack("S<")
|
||||
end
|
||||
|
||||
header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT"
|
||||
header + body + "\x00\x00"
|
||||
end
|
||||
|
||||
def write_activity_fit(records, sport: nil)
|
||||
path = File.join(Dir.mktmpdir, "activity.fit")
|
||||
File.binwrite(path, build_activity_fit(records, sport: sport))
|
||||
path
|
||||
end
|
||||
|
||||
def test_activity_type_comes_from_the_session_messages_sport_enum
|
||||
path = write_activity_fit([{ lat: 100_000_000, lon: 200_000_000 }], sport: 2)
|
||||
geojson = Maptool::Fit.to_geojson(path)
|
||||
|
||||
assert_equal "cycling", geojson["features"][0]["properties"]["activity_type"]
|
||||
end
|
||||
|
||||
def test_activity_type_is_nil_without_a_session_message
|
||||
path = write_activity_fit([{ lat: 100_000_000, lon: 200_000_000 }])
|
||||
geojson = Maptool::Fit.to_geojson(path)
|
||||
|
||||
assert_nil geojson["features"][0]["properties"]["activity_type"]
|
||||
end
|
||||
|
||||
def test_profile_carries_heart_rate_cadence_and_speed_treating_0xff_as_absent
|
||||
path = write_activity_fit([
|
||||
{ lat: 100_000_000, lon: 200_000_000, heart_rate: 140, cadence: 80, speed_mps: 3.5 },
|
||||
{ lat: 100_000_100, lon: 200_000_100 }, # no extras -> 0xFF/0xFFFF sentinels
|
||||
])
|
||||
|
||||
profile = Maptool::Fit.to_geojson(path)["features"][0]["properties"]["profile"]
|
||||
|
||||
assert_equal 140, profile[0]["heart_rate_bpm"]
|
||||
assert_equal 80, profile[0]["cadence_rpm"]
|
||||
assert_in_delta 3.5, profile[0]["speed_mps"]
|
||||
assert_nil profile[1]["heart_rate_bpm"]
|
||||
assert_nil profile[1]["speed_mps"]
|
||||
end
|
||||
end
|
||||
@@ -23,11 +23,24 @@ class GpxTest < Minitest::Test
|
||||
assert_equal "MultiLineString", track["geometry"]["type"]
|
||||
assert_equal 3, track["geometry"]["coordinates"][0].size
|
||||
assert_equal "Morning Loop", track["properties"]["name"]
|
||||
assert_equal "running", track["properties"]["activity_type"]
|
||||
assert_operator track["properties"]["distance_meters"], :>, 0
|
||||
assert_equal 50.0, track["properties"]["elevation_gain_meters"], "200->250 is the only gain, 250->230 descent not subtracted"
|
||||
assert_equal 1200, track["properties"]["duration_secs"]
|
||||
end
|
||||
|
||||
def test_track_profile_carries_extension_fields_and_tolerates_points_without_them
|
||||
geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx"))
|
||||
profile = geojson["features"].find { |f| f["properties"]["kind"] == "track" }["properties"]["profile"]
|
||||
|
||||
assert_equal 3, profile.size
|
||||
assert_equal 140, profile[0]["heart_rate_bpm"]
|
||||
assert_equal 84, profile[1]["cadence_rpm"]
|
||||
assert_nil profile[2]["heart_rate_bpm"], "third point has no <extensions>"
|
||||
assert_equal 0.0, profile[0]["distance_meters"]
|
||||
assert_equal [17.2, 48.1], [profile[0]["lon"], profile[0]["lat"]]
|
||||
end
|
||||
|
||||
def test_invalid_xml_raises_parse_error
|
||||
path = File.join(Dir.mktmpdir, "bad.gpx")
|
||||
File.write(path, "<gpx><trk unclosed>")
|
||||
|
||||
@@ -32,4 +32,26 @@ class TrackStatsTest < Minitest::Test
|
||||
stats = Maptool::TrackStats.compute([[17.0, 48.0]])
|
||||
assert_equal 0.0, stats["distance_meters"]
|
||||
end
|
||||
|
||||
def test_build_profile_has_cumulative_distance_elevation_and_extras
|
||||
points = [[17.0, 48.0, 100], [17.001, 48.0, 150]]
|
||||
extras = [{ heart_rate_bpm: 120 }, { heart_rate_bpm: 130, cadence_rpm: 80 }]
|
||||
|
||||
profile = Maptool::TrackStats.build_profile(points, extras: extras)
|
||||
|
||||
assert_equal 2, profile.size
|
||||
assert_equal 0.0, profile[0]["distance_meters"]
|
||||
assert_equal 100, profile[0]["elevation_meters"]
|
||||
assert_equal 120, profile[0]["heart_rate_bpm"]
|
||||
assert_nil profile[0]["cadence_rpm"]
|
||||
assert_operator profile[1]["distance_meters"], :>, 0
|
||||
assert_equal 80, profile[1]["cadence_rpm"]
|
||||
assert_equal [17.001, 48.0], [profile[1]["lon"], profile[1]["lat"]]
|
||||
end
|
||||
|
||||
def test_build_profile_extras_default_to_nil_when_shorter_than_coordinates
|
||||
profile = Maptool::TrackStats.build_profile([[17.0, 48.0], [17.001, 48.0]], extras: [])
|
||||
assert_nil profile[0]["heart_rate_bpm"]
|
||||
assert_nil profile[1]["speed_mps"]
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user