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.
67 lines
2.5 KiB
Ruby
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
|