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

+78 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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