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
+54
-15
@@ -234,21 +234,60 @@ matched URL can never break out of the `href="..."` attribute. Every
|
|||||||
other value is HTML-escaped before being handed to `Popup#setHTML`.
|
other value is HTML-escaped before being handed to `Popup#setHTML`.
|
||||||
|
|
||||||
Track properties:: Every track feature (both formats) also carries
|
Track properties:: Every track feature (both formats) also carries
|
||||||
`distance_meters`, `elevation_gain_meters`, `duration_secs` and
|
`distance_meters`, `elevation_gain_meters`, `duration_secs`,
|
||||||
`point_count`, computed once server-side by `Maptool::TrackStats`
|
`point_count` and `activity_type`, computed once server-side by
|
||||||
(`lib/maptool/track_stats.rb`, shared by `Gpx`/`Fit` so neither
|
`Maptool::TrackStats` (`lib/maptool/track_stats.rb`, shared by
|
||||||
duplicates the haversine formula) rather than in the browser — cheap
|
`Gpx`/`Fit` so neither duplicates the haversine formula) rather than
|
||||||
either way, but it means a GPX and a FIT file end up with the same
|
in the browser — cheap either way, but it means a GPX and a FIT file
|
||||||
properties shape, so the frontend doesn't need to know which format it
|
end up with the same properties shape, so the frontend doesn't need to
|
||||||
got. Distance sums the haversine distance between consecutive points;
|
know which format it got. Distance sums the haversine distance between
|
||||||
elevation gain sums only the positive deltas (descents don't
|
consecutive points; elevation gain sums only the positive deltas
|
||||||
subtract); duration is the gap between the first and last known
|
(descents don't subtract); duration is the gap between the first and
|
||||||
timestamp, skipping unknown ones in between — `nil` for any of these
|
last known timestamp, skipping unknown ones in between — `nil` for any
|
||||||
where the source data doesn't have enough to compute it (e.g. a GPX
|
of these where the source data doesn't have enough to compute it (e.g.
|
||||||
file with no `<time>` elements has `duration_secs: nil`). Hovering a
|
a GPX file with no `<time>` elements has `duration_secs: nil`).
|
||||||
track line (rather than clicking, unlike a waypoint — these properties
|
`activity_type` comes from wherever each format keeps it: FIT's
|
||||||
describe the whole line, not whichever point the cursor happens to be
|
`session`/`sport` message has a `sport` enum field (`Fit::SPORT_NAMES`
|
||||||
over) shows them in the same popup mechanism.
|
— Garmin's own table, github.com/garmin/fit-python-sdk's `profile.py`,
|
||||||
|
not exhaustive of every value Garmin has defined but every mainstream
|
||||||
|
one); GPX's `<trk><type>` is free text, whatever a device/app chose to
|
||||||
|
write, passed through as-is. Hovering a track line (rather than
|
||||||
|
clicking, unlike a waypoint — these properties describe the whole
|
||||||
|
line, not whichever point the cursor happens to be over) shows them in
|
||||||
|
the same popup mechanism as waypoints; the popup also hints that
|
||||||
|
clicking opens the full profile panel below.
|
||||||
|
|
||||||
|
Track profile panel:: Clicking a track line opens a bottom panel
|
||||||
|
(`public/js/track_profile.js`, `TrackProfilePanel`) with a chart per
|
||||||
|
metric the track actually has data for — elevation always (assuming
|
||||||
|
any point carries an `<ele>`/altitude), speed/cadence/heart rate only
|
||||||
|
when present. `Maptool::TrackStats.build_profile` (called from both
|
||||||
|
`Gpx`/`Fit`, alongside `.compute` above) produces this: one entry per
|
||||||
|
point with cumulative distance (computed the same way for every
|
||||||
|
format, not trusted from a device's own odometer field, so a GPX and a
|
||||||
|
FIT track are directly comparable), elevation, and whatever
|
||||||
|
speed/cadence/heart-rate extras that point's format/file carried —
|
||||||
|
`nil` fields simply don't render their chart. For FIT, those extras
|
||||||
|
are the `record` message's own `heart_rate`/`cadence`/`speed` fields
|
||||||
|
(3/4/6); for GPX, a device-specific `<extensions>` block under each
|
||||||
|
`<trkpt>` — usually Garmin's `TrackPointExtension`, matched by local
|
||||||
|
element name only (`hr`/`cad`/`speed`) so it doesn't care which XML
|
||||||
|
namespace prefix a given file happens to declare.
|
||||||
|
+
|
||||||
|
Each chart is its own single-series `<svg>` (elevation/speed/cadence/
|
||||||
|
heart rate are on wildly different scales, so this is small multiples
|
||||||
|
rather than one chart with several y-axes) with a fixed `viewBox` width, stretched to
|
||||||
|
the container by CSS; mouse interaction converts `clientX` through the
|
||||||
|
container's current `getBoundingClientRect()` on every move rather
|
||||||
|
than fighting the SVG's own coordinate transform, so it stays correct
|
||||||
|
across container width without redrawing — though a browser resize
|
||||||
|
while the panel is open won't reflow the already-drawn paths until
|
||||||
|
it's reopened. One `pointermove` listener on the whole chart container
|
||||||
|
(not per-chart) finds the nearest profile point by distance and drives
|
||||||
|
three things at once: a synchronized crosshair line in every chart, a
|
||||||
|
single tooltip listing every active series' value at that point, and a
|
||||||
|
MapLibre `Marker` moved to that point's `lon`/`lat` on the map — so
|
||||||
|
hovering the chart shows where on the track that moment actually was.
|
||||||
|
|
||||||
== Live GNSS position
|
== Live GNSS position
|
||||||
|
|
||||||
|
|||||||
+78
-18
@@ -4,23 +4,47 @@ module Maptool
|
|||||||
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
|
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
|
||||||
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
|
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
|
||||||
# for a <trk>: one Feature per file, geometry MultiLineString, "kind" =>
|
# for a <trk>: one Feature per file, geometry MultiLineString, "kind" =>
|
||||||
# "track". Only positioned "record" messages are read — FIT also carries
|
# "track". Only "record" messages (the track points) and one "sport"/
|
||||||
# laps/sessions/events/device info/developer fields, none of which this
|
# "session" field (the activity type) are read — FIT also carries laps,
|
||||||
# app has anywhere to show, so they're skipped by byte-length rather than
|
# events, device info, developer fields, none of which this app has
|
||||||
|
# anywhere to show, so they're skipped by byte-length rather than
|
||||||
# interpreted.
|
# interpreted.
|
||||||
module Fit
|
module Fit
|
||||||
class ParseError < StandardError; end
|
class ParseError < StandardError; end
|
||||||
|
|
||||||
FIT_EPOCH = Time.utc(1989, 12, 31).to_i
|
FIT_EPOCH = Time.utc(1989, 12, 31).to_i
|
||||||
RECORD_MESSAGE_NUM = 20
|
RECORD_MESSAGE_NUM = 20
|
||||||
|
SPORT_MESSAGE_NUM = 12
|
||||||
|
SESSION_MESSAGE_NUM = 18
|
||||||
FIELD_POSITION_LAT = 0
|
FIELD_POSITION_LAT = 0
|
||||||
FIELD_POSITION_LONG = 1
|
FIELD_POSITION_LONG = 1
|
||||||
FIELD_ALTITUDE = 2
|
FIELD_ALTITUDE = 2
|
||||||
|
FIELD_HEART_RATE = 3
|
||||||
|
FIELD_CADENCE = 4
|
||||||
|
FIELD_SPEED = 6
|
||||||
FIELD_TIMESTAMP = 253
|
FIELD_TIMESTAMP = 253
|
||||||
FIELD_ENHANCED_ALTITUDE = 78
|
FIELD_ENHANCED_ALTITUDE = 78
|
||||||
|
SPORT_FIELD_BY_MESSAGE = { SPORT_MESSAGE_NUM => 0, SESSION_MESSAGE_NUM => 5 }.freeze
|
||||||
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
|
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)
|
Definition = Struct.new(:global_mesg_num, :little_endian, :fields, :total_size)
|
||||||
|
ParseResult = Struct.new(:records, :activity_type)
|
||||||
|
|
||||||
def self.kinds(path)
|
def self.kinds(path)
|
||||||
to_geojson(path)["features"].empty? ? [] : ["track"]
|
to_geojson(path)["features"].empty? ? [] : ["track"]
|
||||||
@@ -29,16 +53,21 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.to_geojson(path)
|
def self.to_geojson(path)
|
||||||
records = parse(File.binread(path))
|
result = parse(File.binread(path))
|
||||||
features = records.empty? ? [] : [track_feature(records.map(&:first), records.map(&:last))]
|
features = result.records.empty? ? [] : [track_feature(result)]
|
||||||
{ "type" => "FeatureCollection", "features" => features }
|
{ "type" => "FeatureCollection", "features" => features }
|
||||||
end
|
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",
|
"type" => "Feature",
|
||||||
"geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] },
|
"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
|
end
|
||||||
private_class_method :track_feature
|
private_class_method :track_feature
|
||||||
@@ -54,6 +83,7 @@ module Maptool
|
|||||||
offset = header_size
|
offset = header_size
|
||||||
definitions = {}
|
definitions = {}
|
||||||
records = []
|
records = []
|
||||||
|
activity_type = nil
|
||||||
|
|
||||||
while offset < data_end
|
while offset < data_end
|
||||||
header = data.getbyte(offset)
|
header = data.getbyte(offset)
|
||||||
@@ -62,7 +92,8 @@ module Maptool
|
|||||||
if header & 0x80 != 0
|
if header & 0x80 != 0
|
||||||
local_type = (header >> 5) & 0x3
|
local_type = (header >> 5) & 0x3
|
||||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
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
|
elsif header & 0x40 != 0
|
||||||
local_type = header & 0x0F
|
local_type = header & 0x0F
|
||||||
definition, offset = read_definition(data, offset, header & 0x20 != 0)
|
definition, offset = read_definition(data, offset, header & 0x20 != 0)
|
||||||
@@ -70,11 +101,12 @@ module Maptool
|
|||||||
else
|
else
|
||||||
local_type = header & 0x0F
|
local_type = header & 0x0F
|
||||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
records
|
ParseResult.new(records, activity_type)
|
||||||
rescue ArgumentError, NoMethodError => e
|
rescue ArgumentError, NoMethodError => e
|
||||||
raise ParseError, e.message
|
raise ParseError, e.message
|
||||||
end
|
end
|
||||||
@@ -107,20 +139,38 @@ module Maptool
|
|||||||
end
|
end
|
||||||
private_class_method :read_definition
|
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)
|
def self.read_data_message(data, offset, definition, records)
|
||||||
|
sport = nil
|
||||||
if definition.global_mesg_num == RECORD_MESSAGE_NUM
|
if definition.global_mesg_num == RECORD_MESSAGE_NUM
|
||||||
record = extract_record(data, offset, definition)
|
record = extract_record(data, offset, definition)
|
||||||
records << record if record
|
records << record if record
|
||||||
|
elsif (field_num = SPORT_FIELD_BY_MESSAGE[definition.global_mesg_num])
|
||||||
|
sport = extract_sport(data, offset, definition, field_num)
|
||||||
end
|
end
|
||||||
offset + definition.total_size
|
[offset + definition.total_size, sport]
|
||||||
end
|
end
|
||||||
private_class_method :read_data_message
|
private_class_method :read_data_message
|
||||||
|
|
||||||
# Only reads the 4 fields the map/its properties need; every other
|
def self.extract_sport(data, offset, definition, target_field_num)
|
||||||
# field in the message (heart rate, cadence, power, ...) is walked past
|
pos = offset
|
||||||
# via its declared size without being interpreted.
|
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)
|
def self.extract_record(data, offset, definition)
|
||||||
lat = lon = altitude = timestamp = nil
|
lat = lon = altitude = timestamp = heart_rate = cadence = speed = nil
|
||||||
pos = offset
|
pos = offset
|
||||||
|
|
||||||
definition.fields.each do |field_num, size|
|
definition.fields.each do |field_num, size|
|
||||||
@@ -133,6 +183,12 @@ module Maptool
|
|||||||
altitude ||= unpack_int(data, pos, size, definition.little_endian, signed: false)
|
altitude ||= unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||||
when FIELD_TIMESTAMP
|
when FIELD_TIMESTAMP
|
||||||
timestamp = unpack_int(data, pos, size, definition.little_endian, signed: false)
|
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
|
end
|
||||||
pos += size
|
pos += size
|
||||||
end
|
end
|
||||||
@@ -142,7 +198,11 @@ module Maptool
|
|||||||
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
|
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
|
||||||
coordinate << (altitude / 5.0 - 500) if altitude && !invalid?(altitude, 2) && !invalid?(altitude, 4)
|
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)
|
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
|
end
|
||||||
private_class_method :extract_record
|
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
|
# FIT's per-base-type "field not present" sentinel: all bits set for an
|
||||||
# unsigned/positive-only reading, the same pattern for signed. Good
|
# unsigned/positive-only reading, the same pattern for signed. Good
|
||||||
# enough for the two fields this cares about (position, altitude)
|
# enough for the fields this cares about without needing every base
|
||||||
# without needing every base type's exact invalid value.
|
# type's exact invalid value.
|
||||||
def self.invalid?(value, size)
|
def self.invalid?(value, size)
|
||||||
value == (1 << (size * 8 - 1)) - 1 || value == -1 || value == (1 << (size * 8)) - 1
|
value == (1 << (size * 8 - 1)) - 1 || value == -1 || value == (1 << (size * 8)) - 1
|
||||||
end
|
end
|
||||||
|
|||||||
+23
-2
@@ -54,6 +54,7 @@ module Maptool
|
|||||||
lines = []
|
lines = []
|
||||||
all_points = []
|
all_points = []
|
||||||
times = []
|
times = []
|
||||||
|
extras = []
|
||||||
trk.each_element("trkseg") do |seg|
|
trk.each_element("trkseg") do |seg|
|
||||||
points = []
|
points = []
|
||||||
seg.each_element("trkpt") do |pt|
|
seg.each_element("trkpt") do |pt|
|
||||||
@@ -61,6 +62,7 @@ module Maptool
|
|||||||
points << point
|
points << point
|
||||||
all_points << point
|
all_points << point
|
||||||
times << parse_time(child_text(pt, "time"))
|
times << parse_time(child_text(pt, "time"))
|
||||||
|
extras << point_extensions(pt)
|
||||||
end
|
end
|
||||||
lines << points unless points.empty?
|
lines << points unless points.empty?
|
||||||
end
|
end
|
||||||
@@ -68,12 +70,31 @@ module Maptool
|
|||||||
{
|
{
|
||||||
"type" => "Feature",
|
"type" => "Feature",
|
||||||
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
||||||
"properties" => { "kind" => "track", "name" => child_text(trk, "name") }
|
"properties" => { "kind" => "track", "name" => child_text(trk, "name"), "activity_type" => child_text(trk, "type") }
|
||||||
.merge(TrackStats.compute(all_points, times: times)),
|
.merge(TrackStats.compute(all_points, times: times))
|
||||||
|
.merge("profile" => TrackStats.build_profile(all_points, extras: extras)),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
private_class_method :track_feature
|
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)
|
def self.parse_time(text)
|
||||||
text && Time.parse(text)
|
text && Time.parse(text)
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
|
|||||||
@@ -27,6 +27,33 @@ module Maptool
|
|||||||
}
|
}
|
||||||
end
|
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)
|
def self.haversine(lat1, lon1, lat2, lon2)
|
||||||
rad = Math::PI / 180
|
rad = Math::PI / 180
|
||||||
dlat = (lat2 - lat1) * rad
|
dlat = (lat2 - lat1) * rad
|
||||||
|
|||||||
@@ -504,3 +504,115 @@ html, body {
|
|||||||
color: #888;
|
color: #888;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 52px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-height: 45vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fcfcfb;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e1e0d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__stats {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #52514e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #898781;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__charts {
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart + .profile-chart {
|
||||||
|
border-top: 1px solid #e1e0d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #898781;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart__unit {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart__svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart__crosshair {
|
||||||
|
stroke: #898781;
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__tooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: #0b0b0b;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__tooltip[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__tooltip-distance {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel__tooltip-label {
|
||||||
|
color: #c3c2b7;
|
||||||
|
}
|
||||||
+16
-5
@@ -1,6 +1,7 @@
|
|||||||
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||||
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON } from "/js/icons.js";
|
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON } from "/js/icons.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||||
|
import { formatActivityType } from "/js/track_profile.js";
|
||||||
|
|
||||||
const TREE_URL = "/api/gpx/tree";
|
const TREE_URL = "/api/gpx/tree";
|
||||||
const UPLOAD_URL = "/api/gpx";
|
const UPLOAD_URL = "/api/gpx";
|
||||||
@@ -18,8 +19,9 @@ const MOVE_MIME = "application/x-gpx-path";
|
|||||||
// and reorganize by dragging), and toggling their tracks/waypoints on and
|
// and reorganize by dragging), and toggling their tracks/waypoints on and
|
||||||
// off the map.
|
// off the map.
|
||||||
export class GpxPanelControl {
|
export class GpxPanelControl {
|
||||||
constructor(map) {
|
constructor(map, profilePanel) {
|
||||||
this._map = map;
|
this._map = map;
|
||||||
|
this._profilePanel = profilePanel;
|
||||||
this._visible = new Set(); // file paths currently shown on the map
|
this._visible = new Set(); // file paths currently shown on the map
|
||||||
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
|
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
|
||||||
|
|
||||||
@@ -331,9 +333,12 @@ export class GpxPanelControl {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover rather than click: unlike a waypoint, a track's properties
|
// Hover shows a quick summary (unlike a waypoint popup, which is
|
||||||
// (distance, elevation gain, duration) describe the whole line, not
|
// click-triggered — a track's summary describes the whole line, not
|
||||||
// whatever point along it happens to get clicked.
|
// whatever point along it happens to get hovered, so there's no single
|
||||||
|
// right anchor point worth waiting for a click on). Click opens the full
|
||||||
|
// profile panel, which does need the click gesture to avoid flashing
|
||||||
|
// open on every casual hover.
|
||||||
_registerLineInteractivity(layerId) {
|
_registerLineInteractivity(layerId) {
|
||||||
if (this._interactiveLayers.has(layerId)) return;
|
if (this._interactiveLayers.has(layerId)) return;
|
||||||
this._interactiveLayers.add(layerId);
|
this._interactiveLayers.add(layerId);
|
||||||
@@ -353,6 +358,10 @@ export class GpxPanelControl {
|
|||||||
popup?.remove();
|
popup?.remove();
|
||||||
popup = null;
|
popup = null;
|
||||||
});
|
});
|
||||||
|
this._map.on("click", layerId, (event) => {
|
||||||
|
const feature = event.features[0];
|
||||||
|
if (feature) this._profilePanel?.show(feature.properties);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeLayers(path) {
|
_removeLayers(path) {
|
||||||
@@ -488,15 +497,17 @@ function popupHtml({ name, description, time }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// properties come straight from an uploaded GPX/FIT file — untrusted input.
|
// properties come straight from an uploaded GPX/FIT file — untrusted input.
|
||||||
function trackPopupHtml({ name, distance_meters, elevation_gain_meters, duration_secs }) {
|
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
||||||
|
|
||||||
const stats = [];
|
const stats = [];
|
||||||
|
if (activity_type) stats.push(escapeHtml(formatActivityType(activity_type)));
|
||||||
if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`);
|
if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`);
|
||||||
if (typeof elevation_gain_meters === "number") stats.push(`↑ ${Math.round(elevation_gain_meters)} m`);
|
if (typeof elevation_gain_meters === "number") stats.push(`↑ ${Math.round(elevation_gain_meters)} m`);
|
||||||
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
|
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
|
||||||
if (stats.length > 0) parts.push(`<p>${stats.join(" · ")}</p>`);
|
if (stats.length > 0) parts.push(`<p>${stats.join(" · ")}</p>`);
|
||||||
|
parts.push(`<p class="gpx-popup__time">Click for the full profile</p>`);
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -8,6 +8,7 @@ import { RadarControl } from "/js/radar.js";
|
|||||||
import { GpxPanelControl } from "/js/gpx.js";
|
import { GpxPanelControl } from "/js/gpx.js";
|
||||||
import { GnssControl } from "/js/gnss.js";
|
import { GnssControl } from "/js/gnss.js";
|
||||||
import { LiveTrackPanelControl } from "/js/livetrack.js";
|
import { LiveTrackPanelControl } from "/js/livetrack.js";
|
||||||
|
import { TrackProfilePanel } from "/js/track_profile.js";
|
||||||
import { LAYERS_ICON } from "/js/icons.js";
|
import { LAYERS_ICON } from "/js/icons.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||||
|
|
||||||
@@ -125,7 +126,9 @@ const layerToggle = new LayerToggleControl(applyStyle);
|
|||||||
layerToggle.mount(sidebar);
|
layerToggle.mount(sidebar);
|
||||||
layerToggle.setActive("online");
|
layerToggle.setActive("online");
|
||||||
|
|
||||||
new GpxPanelControl(map).mount(sidebar);
|
const profilePanel = new TrackProfilePanel(map);
|
||||||
|
profilePanel.mount();
|
||||||
|
new GpxPanelControl(map, profilePanel).mount(sidebar);
|
||||||
new RadarControl(map).mount(sidebar);
|
new RadarControl(map).mount(sidebar);
|
||||||
new GnssControl(map).mount(sidebar);
|
new GnssControl(map).mount(sidebar);
|
||||||
new LiveTrackPanelControl(map).mount(sidebar);
|
new LiveTrackPanelControl(map).mount(sidebar);
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||||
|
|
||||||
|
// Small-multiples profile chart: one row per metric the track actually has
|
||||||
|
// (elevation always, speed/cadence/heart rate only when present — plenty of
|
||||||
|
// GPX files have none of those), sharing one x-axis (distance) and one
|
||||||
|
// crosshair. Elevation/speed/cadence/heart rate live on wildly different
|
||||||
|
// scales, so this is deliberately several single-series charts stacked
|
||||||
|
// rather than one chart with several y-axes (see dataviz skill: no
|
||||||
|
// dual-axis charts).
|
||||||
|
//
|
||||||
|
// SVG viewBox is a fixed 1000-unit-wide coordinate space (not real pixels)
|
||||||
|
// stretched to the container via CSS — mouse interaction converts
|
||||||
|
// clientX through the container's current bounding rect on every move, so
|
||||||
|
// this stays correct across container width without redrawing, but a
|
||||||
|
// browser window resize while the panel is open won't reflow the drawn
|
||||||
|
// paths until it's reopened. Acceptable for a detail panel that's normally
|
||||||
|
// open briefly; add a resize listener if that turns out to matter.
|
||||||
|
const VIEWBOX_WIDTH = 1000;
|
||||||
|
|
||||||
|
const SERIES = [
|
||||||
|
{ key: "elevation_meters", label: "Elevation", unit: "m", color: "#2a78d6", height: 110, area: true },
|
||||||
|
{ key: "speed_kmh", label: "Speed", unit: "km/h", color: "#1baf7a", height: 70, area: false },
|
||||||
|
{ key: "cadence_rpm", label: "Cadence", unit: "rpm", color: "#eda100", height: 70, area: false },
|
||||||
|
{ key: "heart_rate_bpm", label: "Heart rate", unit: "bpm", color: "#e34948", height: 70, area: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class TrackProfilePanel {
|
||||||
|
constructor(map) {
|
||||||
|
this._map = map;
|
||||||
|
this._profile = null;
|
||||||
|
this._marker = new Marker({ color: "#7209b7" });
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
this._panel = document.getElementById("profile-panel");
|
||||||
|
this._title = this._panel.querySelector(".profile-panel__title");
|
||||||
|
this._stats = this._panel.querySelector(".profile-panel__stats");
|
||||||
|
this._chartsContainer = this._panel.querySelector(".profile-panel__charts");
|
||||||
|
this._panel.querySelector(".profile-panel__close").addEventListener("click", () => this.hide());
|
||||||
|
}
|
||||||
|
|
||||||
|
show(properties) {
|
||||||
|
const profile = properties.profile;
|
||||||
|
if (!profile || profile.length === 0) return;
|
||||||
|
|
||||||
|
this._profile = profile.map((point) => ({ ...point, speed_kmh: typeof point.speed_mps === "number" ? point.speed_mps * 3.6 : null }));
|
||||||
|
this._maxDistance = this._profile.at(-1).distance_meters || 1;
|
||||||
|
|
||||||
|
this._title.textContent = properties.name || "Track";
|
||||||
|
this._stats.textContent = statsText(properties);
|
||||||
|
|
||||||
|
this._chartsContainer.innerHTML = "";
|
||||||
|
this._tooltip = document.createElement("div");
|
||||||
|
this._tooltip.className = "profile-panel__tooltip";
|
||||||
|
this._tooltip.hidden = true;
|
||||||
|
this._chartsContainer.appendChild(this._tooltip);
|
||||||
|
|
||||||
|
this._activeSeries = SERIES.filter((s) => this._profile.some((p) => typeof p[s.key] === "number"));
|
||||||
|
this._activeSeries.forEach((series) => this._chartsContainer.appendChild(this._renderChart(series)));
|
||||||
|
|
||||||
|
this._chartsContainer.addEventListener("pointermove", this._onPointerMove);
|
||||||
|
this._chartsContainer.addEventListener("pointerleave", this._onPointerLeave);
|
||||||
|
|
||||||
|
this._panel.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this._panel.hidden = true;
|
||||||
|
this._chartsContainer.removeEventListener("pointermove", this._onPointerMove);
|
||||||
|
this._chartsContainer.removeEventListener("pointerleave", this._onPointerLeave);
|
||||||
|
this._marker.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderChart(series) {
|
||||||
|
const values = this._profile.map((p) => p[series.key]).filter((v) => typeof v === "number");
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
const toX = (d) => (d / this._maxDistance) * VIEWBOX_WIDTH;
|
||||||
|
const toY = (v) => series.height - 6 - ((v - min) / range) * (series.height - 12);
|
||||||
|
|
||||||
|
const points = this._profile
|
||||||
|
.map((p) => (typeof p[series.key] === "number" ? [toX(p.distance_meters), toY(p[series.key])] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
const linePath = points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
|
||||||
|
const areaPath = series.area && points.length > 0
|
||||||
|
? `${linePath} L${points.at(-1)[0].toFixed(1)},${series.height} L0,${series.height} Z`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "profile-chart";
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<div class="profile-chart__label">${escapeHtml(series.label)} <span class="profile-chart__unit">(${escapeHtml(series.unit)})</span></div>
|
||||||
|
<svg viewBox="0 0 ${VIEWBOX_WIDTH} ${series.height}" preserveAspectRatio="none" class="profile-chart__svg" style="height:${series.height}px">
|
||||||
|
${areaPath ? `<path d="${areaPath}" fill="${series.color}" fill-opacity="0.15" stroke="none"></path>` : ""}
|
||||||
|
<path d="${linePath}" fill="none" stroke="${series.color}" stroke-width="2" vector-effect="non-scaling-stroke"></path>
|
||||||
|
<line class="profile-chart__crosshair" x1="0" x2="0" y1="0" y2="${series.height}" hidden></line>
|
||||||
|
<circle class="profile-chart__dot" r="4" fill="${series.color}" stroke="#fff" stroke-width="1.5" hidden></circle>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
wrapper.dataset.min = min;
|
||||||
|
wrapper.dataset.max = max;
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound once (arrow fields, not prototype methods) so add/removeEventListener see the same reference.
|
||||||
|
_onPointerMove = (event) => {
|
||||||
|
const rect = this._chartsContainer.getBoundingClientRect();
|
||||||
|
const fraction = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||||
|
const targetDistance = fraction * this._maxDistance;
|
||||||
|
|
||||||
|
const index = nearestIndex(this._profile, targetDistance);
|
||||||
|
const point = this._profile[index];
|
||||||
|
this._updateCrosshair(point);
|
||||||
|
this._updateTooltip(point, event);
|
||||||
|
this._marker.setLngLat([point.lon, point.lat]).addTo(this._map);
|
||||||
|
};
|
||||||
|
|
||||||
|
_onPointerLeave = () => {
|
||||||
|
this._tooltip.hidden = true;
|
||||||
|
this._chartsContainer.querySelectorAll(".profile-chart__crosshair, .profile-chart__dot").forEach((el) => (el.hidden = true));
|
||||||
|
this._marker.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
_updateCrosshair(point) {
|
||||||
|
const x = (point.distance_meters / this._maxDistance) * VIEWBOX_WIDTH;
|
||||||
|
this._chartsContainer.querySelectorAll(".profile-chart").forEach((wrapper, i) => {
|
||||||
|
const series = this._activeSeries[i];
|
||||||
|
const value = point[series.key];
|
||||||
|
const svg = wrapper.querySelector("svg");
|
||||||
|
const crosshair = wrapper.querySelector(".profile-chart__crosshair");
|
||||||
|
const dot = wrapper.querySelector(".profile-chart__dot");
|
||||||
|
crosshair.setAttribute("x1", x.toFixed(1));
|
||||||
|
crosshair.setAttribute("x2", x.toFixed(1));
|
||||||
|
crosshair.hidden = false;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
const min = Number(wrapper.dataset.min);
|
||||||
|
const max = Number(wrapper.dataset.max);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const y = series.height - 6 - ((value - min) / range) * (series.height - 12);
|
||||||
|
dot.setAttribute("cx", x.toFixed(1));
|
||||||
|
dot.setAttribute("cy", y.toFixed(1));
|
||||||
|
dot.hidden = false;
|
||||||
|
} else {
|
||||||
|
dot.hidden = true;
|
||||||
|
}
|
||||||
|
void svg; // svg itself needs no direct update, its children were mutated above
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateTooltip(point, event) {
|
||||||
|
const rows = this._activeSeries
|
||||||
|
.filter((s) => typeof point[s.key] === "number")
|
||||||
|
.map((s) => `<div><span class="profile-panel__tooltip-label">${escapeHtml(s.label)}</span> <strong>${formatValue(point[s.key], s)}</strong></div>`)
|
||||||
|
.join("");
|
||||||
|
this._tooltip.innerHTML = `<div class="profile-panel__tooltip-distance">${(point.distance_meters / 1000).toFixed(2)} km</div>${rows}`;
|
||||||
|
this._tooltip.hidden = false;
|
||||||
|
|
||||||
|
const containerRect = this._chartsContainer.getBoundingClientRect();
|
||||||
|
const left = Math.min(event.clientX - containerRect.left + 12, containerRect.width - this._tooltip.offsetWidth - 8);
|
||||||
|
this._tooltip.style.left = `${Math.max(8, left)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestIndex(profile, targetDistance) {
|
||||||
|
// Distances are monotonically non-decreasing (see TrackStats.build_profile),
|
||||||
|
// so a binary search would work too — linear is simpler and profiles here
|
||||||
|
// top out in the low thousands of points, not worth the complexity.
|
||||||
|
let closest = 0;
|
||||||
|
let closestDiff = Infinity;
|
||||||
|
profile.forEach((p, i) => {
|
||||||
|
const diff = Math.abs(p.distance_meters - targetDistance);
|
||||||
|
if (diff < closestDiff) {
|
||||||
|
closestDiff = diff;
|
||||||
|
closest = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value, series) {
|
||||||
|
if (series.key === "elevation_meters") return `${Math.round(value)} ${series.unit}`;
|
||||||
|
if (series.key === "speed_kmh") return `${value.toFixed(1)} ${series.unit}`;
|
||||||
|
return `${Math.round(value)} ${series.unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsText({ activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
|
||||||
|
const parts = [];
|
||||||
|
if (activity_type) parts.push(formatActivityType(activity_type));
|
||||||
|
if (typeof distance_meters === "number") parts.push(`${(distance_meters / 1000).toFixed(1)} km`);
|
||||||
|
if (typeof elevation_gain_meters === "number") parts.push(`↑ ${Math.round(elevation_gain_meters)} m`);
|
||||||
|
if (typeof duration_secs === "number") parts.push(formatDuration(duration_secs));
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIT gives snake_case ("cross_country_skiing"); GPX's free-text <type> can
|
||||||
|
// be anything a device/app chose to write ("VirtualRide", "running", ...) —
|
||||||
|
// this reads reasonably for both without needing to know which format the
|
||||||
|
// track came from.
|
||||||
|
export function formatActivityType(text) {
|
||||||
|
return text
|
||||||
|
.split("_")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(totalSeconds) {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
Vendored
+13
@@ -8,14 +8,27 @@
|
|||||||
</wpt>
|
</wpt>
|
||||||
<trk>
|
<trk>
|
||||||
<name>Morning Loop</name>
|
<name>Morning Loop</name>
|
||||||
|
<type>running</type>
|
||||||
<trkseg>
|
<trkseg>
|
||||||
<trkpt lat="48.1" lon="17.2">
|
<trkpt lat="48.1" lon="17.2">
|
||||||
<ele>200</ele>
|
<ele>200</ele>
|
||||||
<time>2026-01-01T09:00:00Z</time>
|
<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>
|
||||||
<trkpt lat="48.101" lon="17.201">
|
<trkpt lat="48.101" lon="17.201">
|
||||||
<ele>250</ele>
|
<ele>250</ele>
|
||||||
<time>2026-01-01T09:10:00Z</time>
|
<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>
|
||||||
<trkpt lat="48.102" lon="17.202">
|
<trkpt lat="48.102" lon="17.202">
|
||||||
<ele>230</ele>
|
<ele>230</ele>
|
||||||
|
|||||||
@@ -57,4 +57,68 @@ class FitTest < Minitest::Test
|
|||||||
|
|
||||||
assert_raises(Maptool::Fit::ParseError) { Maptool::Fit.to_geojson(path) }
|
assert_raises(Maptool::Fit::ParseError) { Maptool::Fit.to_geojson(path) }
|
||||||
end
|
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
|
end
|
||||||
@@ -23,11 +23,24 @@ class GpxTest < Minitest::Test
|
|||||||
assert_equal "MultiLineString", track["geometry"]["type"]
|
assert_equal "MultiLineString", track["geometry"]["type"]
|
||||||
assert_equal 3, track["geometry"]["coordinates"][0].size
|
assert_equal 3, track["geometry"]["coordinates"][0].size
|
||||||
assert_equal "Morning Loop", track["properties"]["name"]
|
assert_equal "Morning Loop", track["properties"]["name"]
|
||||||
|
assert_equal "running", track["properties"]["activity_type"]
|
||||||
assert_operator track["properties"]["distance_meters"], :>, 0
|
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 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"]
|
assert_equal 1200, track["properties"]["duration_secs"]
|
||||||
end
|
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
|
def test_invalid_xml_raises_parse_error
|
||||||
path = File.join(Dir.mktmpdir, "bad.gpx")
|
path = File.join(Dir.mktmpdir, "bad.gpx")
|
||||||
File.write(path, "<gpx><trk unclosed>")
|
File.write(path, "<gpx><trk unclosed>")
|
||||||
|
|||||||
@@ -32,4 +32,26 @@ class TrackStatsTest < Minitest::Test
|
|||||||
stats = Maptool::TrackStats.compute([[17.0, 48.0]])
|
stats = Maptool::TrackStats.compute([[17.0, 48.0]])
|
||||||
assert_equal 0.0, stats["distance_meters"]
|
assert_equal 0.0, stats["distance_meters"]
|
||||||
end
|
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
|
end
|
||||||
@@ -17,6 +17,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="profile-panel" class="profile-panel" hidden>
|
||||||
|
<div class="profile-panel__header">
|
||||||
|
<strong class="profile-panel__title"></strong>
|
||||||
|
<span class="profile-panel__stats"></span>
|
||||||
|
<button type="button" class="profile-panel__close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="profile-panel__charts"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="basemap-panel" class="basemap-panel" hidden>
|
<div id="basemap-panel" class="basemap-panel" hidden>
|
||||||
<div class="basemap-panel__header">
|
<div class="basemap-panel__header">
|
||||||
<strong>Base map</strong>
|
<strong>Base map</strong>
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,8 @@
|
|||||||
"/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>",
|
"/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/livetrack.js": "/js/livetrack.js?v=<%= ASSET_VERSION %>",
|
"/js/livetrack.js": "/js/livetrack.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/radar.js": "/js/radar.js?v=<%= ASSET_VERSION %>"
|
"/js/radar.js": "/js/radar.js?v=<%= ASSET_VERSION %>",
|
||||||
|
"/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in new issue
Block a user