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

+54 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+112
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+218
View File
@@ -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;
}
+13
View File
@@ -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>
+64
View File
@@ -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
+13
View File
@@ -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>")
+22
View File
@@ -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
+9
View File
@@ -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">&times;</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
View File
@@ -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>