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
+78
-18
@@ -4,23 +4,47 @@ module Maptool
|
||||
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
|
||||
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
|
||||
# for a <trk>: one Feature per file, geometry MultiLineString, "kind" =>
|
||||
# "track". Only positioned "record" messages are read — FIT also carries
|
||||
# laps/sessions/events/device info/developer fields, none of which this
|
||||
# app has anywhere to show, so they're skipped by byte-length rather than
|
||||
# "track". Only "record" messages (the track points) and one "sport"/
|
||||
# "session" field (the activity type) are read — FIT also carries laps,
|
||||
# events, device info, developer fields, none of which this app has
|
||||
# anywhere to show, so they're skipped by byte-length rather than
|
||||
# interpreted.
|
||||
module Fit
|
||||
class ParseError < StandardError; end
|
||||
|
||||
FIT_EPOCH = Time.utc(1989, 12, 31).to_i
|
||||
RECORD_MESSAGE_NUM = 20
|
||||
SPORT_MESSAGE_NUM = 12
|
||||
SESSION_MESSAGE_NUM = 18
|
||||
FIELD_POSITION_LAT = 0
|
||||
FIELD_POSITION_LONG = 1
|
||||
FIELD_ALTITUDE = 2
|
||||
FIELD_HEART_RATE = 3
|
||||
FIELD_CADENCE = 4
|
||||
FIELD_SPEED = 6
|
||||
FIELD_TIMESTAMP = 253
|
||||
FIELD_ENHANCED_ALTITUDE = 78
|
||||
SPORT_FIELD_BY_MESSAGE = { SPORT_MESSAGE_NUM => 0, SESSION_MESSAGE_NUM => 5 }.freeze
|
||||
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
|
||||
|
||||
# Garmin's own sport enum (github.com/garmin/fit-python-sdk's
|
||||
# profile.py, `types.sport`) — not exhaustive of every value Garmin has
|
||||
# ever defined (rare ones omitted), but every mainstream activity type.
|
||||
SPORT_NAMES = {
|
||||
0 => "generic", 1 => "running", 2 => "cycling", 3 => "transition",
|
||||
4 => "fitness_equipment", 5 => "swimming", 6 => "basketball", 7 => "soccer",
|
||||
8 => "tennis", 9 => "american_football", 10 => "training", 11 => "walking",
|
||||
12 => "cross_country_skiing", 13 => "alpine_skiing", 14 => "snowboarding",
|
||||
15 => "rowing", 16 => "mountaineering", 17 => "hiking", 18 => "multisport",
|
||||
19 => "paddling", 20 => "flying", 21 => "e_biking", 22 => "motorcycling",
|
||||
23 => "boating", 24 => "driving", 25 => "golf", 30 => "inline_skating",
|
||||
31 => "rock_climbing", 32 => "sailing", 33 => "ice_skating", 37 => "stand_up_paddleboarding",
|
||||
38 => "surfing", 39 => "wakeboarding", 40 => "water_skiing", 41 => "kayaking",
|
||||
49 => "baseball", 53 => "diving", 62 => "hiit", 87 => "geocaching", 88 => "canoeing"
|
||||
}.freeze
|
||||
|
||||
Definition = Struct.new(:global_mesg_num, :little_endian, :fields, :total_size)
|
||||
ParseResult = Struct.new(:records, :activity_type)
|
||||
|
||||
def self.kinds(path)
|
||||
to_geojson(path)["features"].empty? ? [] : ["track"]
|
||||
@@ -29,16 +53,21 @@ module Maptool
|
||||
end
|
||||
|
||||
def self.to_geojson(path)
|
||||
records = parse(File.binread(path))
|
||||
features = records.empty? ? [] : [track_feature(records.map(&:first), records.map(&:last))]
|
||||
result = parse(File.binread(path))
|
||||
features = result.records.empty? ? [] : [track_feature(result)]
|
||||
{ "type" => "FeatureCollection", "features" => features }
|
||||
end
|
||||
|
||||
def self.track_feature(coordinates, times)
|
||||
def self.track_feature(result)
|
||||
coordinates = result.records.map { |r| r[:coordinate] }
|
||||
extras = result.records.map { |r| { speed_mps: r[:speed_mps], cadence_rpm: r[:cadence], heart_rate_bpm: r[:heart_rate] } }
|
||||
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] },
|
||||
"properties" => { "kind" => "track", "name" => nil }.merge(TrackStats.compute(coordinates, times: times)),
|
||||
"properties" => { "kind" => "track", "name" => nil, "activity_type" => result.activity_type }
|
||||
.merge(TrackStats.compute(coordinates, times: result.records.map { |r| r[:time] }))
|
||||
.merge("profile" => TrackStats.build_profile(coordinates, extras: extras)),
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
@@ -54,6 +83,7 @@ module Maptool
|
||||
offset = header_size
|
||||
definitions = {}
|
||||
records = []
|
||||
activity_type = nil
|
||||
|
||||
while offset < data_end
|
||||
header = data.getbyte(offset)
|
||||
@@ -62,7 +92,8 @@ module Maptool
|
||||
if header & 0x80 != 0
|
||||
local_type = (header >> 5) & 0x3
|
||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
||||
offset = read_data_message(data, offset, definition, records)
|
||||
offset, sport = read_data_message(data, offset, definition, records)
|
||||
activity_type ||= sport
|
||||
elsif header & 0x40 != 0
|
||||
local_type = header & 0x0F
|
||||
definition, offset = read_definition(data, offset, header & 0x20 != 0)
|
||||
@@ -70,11 +101,12 @@ module Maptool
|
||||
else
|
||||
local_type = header & 0x0F
|
||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
||||
offset = read_data_message(data, offset, definition, records)
|
||||
offset, sport = read_data_message(data, offset, definition, records)
|
||||
activity_type ||= sport
|
||||
end
|
||||
end
|
||||
|
||||
records
|
||||
ParseResult.new(records, activity_type)
|
||||
rescue ArgumentError, NoMethodError => e
|
||||
raise ParseError, e.message
|
||||
end
|
||||
@@ -107,20 +139,38 @@ module Maptool
|
||||
end
|
||||
private_class_method :read_definition
|
||||
|
||||
# Returns [next_offset, sport_name_or_nil] — the latter only set when
|
||||
# this message happens to be the one sport/session message carrying it.
|
||||
def self.read_data_message(data, offset, definition, records)
|
||||
sport = nil
|
||||
if definition.global_mesg_num == RECORD_MESSAGE_NUM
|
||||
record = extract_record(data, offset, definition)
|
||||
records << record if record
|
||||
elsif (field_num = SPORT_FIELD_BY_MESSAGE[definition.global_mesg_num])
|
||||
sport = extract_sport(data, offset, definition, field_num)
|
||||
end
|
||||
offset + definition.total_size
|
||||
[offset + definition.total_size, sport]
|
||||
end
|
||||
private_class_method :read_data_message
|
||||
|
||||
# Only reads the 4 fields the map/its properties need; every other
|
||||
# field in the message (heart rate, cadence, power, ...) is walked past
|
||||
# via its declared size without being interpreted.
|
||||
def self.extract_sport(data, offset, definition, target_field_num)
|
||||
pos = offset
|
||||
definition.fields.each do |field_num, size|
|
||||
if field_num == target_field_num
|
||||
value = unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
return SPORT_NAMES[value]
|
||||
end
|
||||
pos += size
|
||||
end
|
||||
nil
|
||||
end
|
||||
private_class_method :extract_sport
|
||||
|
||||
# Only reads the fields the map/its properties need; every other field
|
||||
# in the message (power, temperature, ...) is walked past via its
|
||||
# declared size without being interpreted.
|
||||
def self.extract_record(data, offset, definition)
|
||||
lat = lon = altitude = timestamp = nil
|
||||
lat = lon = altitude = timestamp = heart_rate = cadence = speed = nil
|
||||
pos = offset
|
||||
|
||||
definition.fields.each do |field_num, size|
|
||||
@@ -133,6 +183,12 @@ module Maptool
|
||||
altitude ||= unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
when FIELD_TIMESTAMP
|
||||
timestamp = unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
when FIELD_HEART_RATE
|
||||
heart_rate = unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
when FIELD_CADENCE
|
||||
cadence = unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
when FIELD_SPEED
|
||||
speed = unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
end
|
||||
pos += size
|
||||
end
|
||||
@@ -142,7 +198,11 @@ module Maptool
|
||||
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
|
||||
coordinate << (altitude / 5.0 - 500) if altitude && !invalid?(altitude, 2) && !invalid?(altitude, 4)
|
||||
time = Time.at(FIT_EPOCH + timestamp).utc if timestamp && !invalid?(timestamp, 4)
|
||||
[coordinate, time]
|
||||
heart_rate = nil if heart_rate.nil? || invalid?(heart_rate, 1)
|
||||
cadence = nil if cadence.nil? || invalid?(cadence, 1)
|
||||
speed_mps = speed && !invalid?(speed, 2) ? speed / 1000.0 : nil
|
||||
|
||||
{ coordinate: coordinate, time: time, heart_rate: heart_rate, cadence: cadence, speed_mps: speed_mps }
|
||||
end
|
||||
private_class_method :extract_record
|
||||
|
||||
@@ -164,8 +224,8 @@ module Maptool
|
||||
|
||||
# FIT's per-base-type "field not present" sentinel: all bits set for an
|
||||
# unsigned/positive-only reading, the same pattern for signed. Good
|
||||
# enough for the two fields this cares about (position, altitude)
|
||||
# without needing every base type's exact invalid value.
|
||||
# enough for the fields this cares about without needing every base
|
||||
# type's exact invalid value.
|
||||
def self.invalid?(value, size)
|
||||
value == (1 << (size * 8 - 1)) - 1 || value == -1 || value == (1 << (size * 8)) - 1
|
||||
end
|
||||
|
||||
+23
-2
@@ -54,6 +54,7 @@ module Maptool
|
||||
lines = []
|
||||
all_points = []
|
||||
times = []
|
||||
extras = []
|
||||
trk.each_element("trkseg") do |seg|
|
||||
points = []
|
||||
seg.each_element("trkpt") do |pt|
|
||||
@@ -61,6 +62,7 @@ module Maptool
|
||||
points << point
|
||||
all_points << point
|
||||
times << parse_time(child_text(pt, "time"))
|
||||
extras << point_extensions(pt)
|
||||
end
|
||||
lines << points unless points.empty?
|
||||
end
|
||||
@@ -68,12 +70,31 @@ module Maptool
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
||||
"properties" => { "kind" => "track", "name" => child_text(trk, "name") }
|
||||
.merge(TrackStats.compute(all_points, times: times)),
|
||||
"properties" => { "kind" => "track", "name" => child_text(trk, "name"), "activity_type" => child_text(trk, "type") }
|
||||
.merge(TrackStats.compute(all_points, times: times))
|
||||
.merge("profile" => TrackStats.build_profile(all_points, extras: extras)),
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
|
||||
# Heart rate/cadence/speed live in a device-specific <extensions> block
|
||||
# (usually Garmin's own TrackPointExtension) — matched by local element
|
||||
# name only (`Element#name` already strips the namespace prefix), so
|
||||
# this doesn't care which extension schema a given file actually
|
||||
# declares, as long as it names its elements the same way Garmin's does.
|
||||
def self.point_extensions(point_el)
|
||||
values = {}
|
||||
point_el.elements.each("extensions//*") do |el|
|
||||
case el.name
|
||||
when "hr" then values[:heart_rate_bpm] = el.text&.strip&.to_i
|
||||
when "cad" then values[:cadence_rpm] = el.text&.strip&.to_i
|
||||
when "speed" then values[:speed_mps] = el.text&.strip&.to_f
|
||||
end
|
||||
end
|
||||
values
|
||||
end
|
||||
private_class_method :point_extensions
|
||||
|
||||
def self.parse_time(text)
|
||||
text && Time.parse(text)
|
||||
rescue ArgumentError
|
||||
|
||||
@@ -27,6 +27,33 @@ module Maptool
|
||||
}
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user