Files
maptool/lib/maptool/track_stats.rb
T
randy 105651f005
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 15s
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.
2026-09-07 13:38:49 +02:00

67 lines
2.5 KiB
Ruby

module Maptool
# Distance/elevation-gain/duration for a track, shared by Gpx and Fit so
# neither has its own copy of the haversine formula.
module TrackStats
EARTH_RADIUS_METERS = 6_371_000
# `coordinates`: array of [lon, lat] or [lon, lat, ele]. `times`: a
# parallel array of Time (or nil where unknown) — only its first and
# last non-nil entries matter, so gaps in the middle are fine.
def self.compute(coordinates, times: [])
distance = 0.0
elevation_gain = 0.0
coordinates.each_cons(2) do |(lon1, lat1, ele1), (lon2, lat2, ele2)|
distance += haversine(lat1, lon1, lat2, lon2)
elevation_gain += ele2 - ele1 if ele1 && ele2 && ele2 > ele1
end
known_times = times.compact
duration = known_times.size >= 2 ? (known_times.last - known_times.first).to_i : nil
{
"distance_meters" => distance.round(1),
"elevation_gain_meters" => elevation_gain.positive? ? elevation_gain.round(1) : nil,
"duration_secs" => duration,
"point_count" => coordinates.size,
}
end
# One entry per coordinate, for the profile chart: cumulative distance
# (computed the same way for every format, rather than trusting a
# device's own odometer field — keeps a GPX and a FIT file directly
# comparable), elevation, and whatever per-point extras (speed/cadence/
# heart rate) the caller has, nil where a format/file doesn't carry it.
# `extras`: array of hashes parallel to `coordinates`, or shorter/empty.
def self.build_profile(coordinates, extras: [])
distance = 0.0
previous = nil
coordinates.map.with_index do |(lon, lat, ele), i|
distance += haversine(previous[1], previous[0], lat, lon) if previous
previous = [lon, lat]
extra = extras[i] || {}
{
"distance_meters" => distance.round(1),
"elevation_meters" => ele,
"speed_mps" => extra[:speed_mps],
"cadence_rpm" => extra[:cadence_rpm],
"heart_rate_bpm" => extra[:heart_rate_bpm],
"lon" => lon,
"lat" => lat,
}
end
end
def self.haversine(lat1, lon1, lat2, lon2)
rad = Math::PI / 180
dlat = (lat2 - lat1) * rad
dlon = (lon2 - lon1) * rad
a = (Math.sin(dlat / 2)**2) + (Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * (Math.sin(dlon / 2)**2))
2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a))
end
private_class_method :haversine
end
end