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`.
Track properties:: Every track feature (both formats) also carries
`distance_meters`, `elevation_gain_meters`, `duration_secs` and
`point_count`, computed once server-side by `Maptool::TrackStats`
(`lib/maptool/track_stats.rb`, shared by `Gpx`/`Fit` so neither
duplicates the haversine formula) rather than in the browser — cheap
either way, but it means a GPX and a FIT file end up with the same
properties shape, so the frontend doesn't need to know which format it
got. Distance sums the haversine distance between consecutive points;
elevation gain sums only the positive deltas (descents don't
subtract); duration is the gap between the first and last known
timestamp, skipping unknown ones in between — `nil` for any of these
where the source data doesn't have enough to compute it (e.g. a GPX
file with no `<time>` elements has `duration_secs: nil`). 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.
`distance_meters`, `elevation_gain_meters`, `duration_secs`,
`point_count` and `activity_type`, computed once server-side by
`Maptool::TrackStats` (`lib/maptool/track_stats.rb`, shared by
`Gpx`/`Fit` so neither duplicates the haversine formula) rather than
in the browser — cheap either way, but it means a GPX and a FIT file
end up with the same properties shape, so the frontend doesn't need to
know which format it got. Distance sums the haversine distance between
consecutive points; elevation gain sums only the positive deltas
(descents don't subtract); duration is the gap between the first and
last known timestamp, skipping unknown ones in between — `nil` for any
of these where the source data doesn't have enough to compute it (e.g.
a GPX file with no `<time>` elements has `duration_secs: nil`).
`activity_type` comes from wherever each format keeps it: FIT's
`session`/`sport` message has a `sport` enum field (`Fit::SPORT_NAMES`
— 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
+78 -18
View File
@@ -4,23 +4,47 @@ module Maptool
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
# for a <trk>: one Feature per file, geometry MultiLineString, "kind" =>
# "track". Only positioned "record" messages are read — FIT also carries
# laps/sessions/events/device info/developer fields, none of which this
# app has anywhere to show, so they're skipped by byte-length rather than
# "track". Only "record" messages (the track points) and one "sport"/
# "session" field (the activity type) are read — FIT also carries laps,
# events, device info, developer fields, none of which this app has
# anywhere to show, so they're skipped by byte-length rather than
# interpreted.
module Fit
class ParseError < StandardError; end
FIT_EPOCH = Time.utc(1989, 12, 31).to_i
RECORD_MESSAGE_NUM = 20
SPORT_MESSAGE_NUM = 12
SESSION_MESSAGE_NUM = 18
FIELD_POSITION_LAT = 0
FIELD_POSITION_LONG = 1
FIELD_ALTITUDE = 2
FIELD_HEART_RATE = 3
FIELD_CADENCE = 4
FIELD_SPEED = 6
FIELD_TIMESTAMP = 253
FIELD_ENHANCED_ALTITUDE = 78
SPORT_FIELD_BY_MESSAGE = { SPORT_MESSAGE_NUM => 0, SESSION_MESSAGE_NUM => 5 }.freeze
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
# Garmin's own sport enum (github.com/garmin/fit-python-sdk's
# profile.py, `types.sport`) — not exhaustive of every value Garmin has
# ever defined (rare ones omitted), but every mainstream activity type.
SPORT_NAMES = {
0 => "generic", 1 => "running", 2 => "cycling", 3 => "transition",
4 => "fitness_equipment", 5 => "swimming", 6 => "basketball", 7 => "soccer",
8 => "tennis", 9 => "american_football", 10 => "training", 11 => "walking",
12 => "cross_country_skiing", 13 => "alpine_skiing", 14 => "snowboarding",
15 => "rowing", 16 => "mountaineering", 17 => "hiking", 18 => "multisport",
19 => "paddling", 20 => "flying", 21 => "e_biking", 22 => "motorcycling",
23 => "boating", 24 => "driving", 25 => "golf", 30 => "inline_skating",
31 => "rock_climbing", 32 => "sailing", 33 => "ice_skating", 37 => "stand_up_paddleboarding",
38 => "surfing", 39 => "wakeboarding", 40 => "water_skiing", 41 => "kayaking",
49 => "baseball", 53 => "diving", 62 => "hiit", 87 => "geocaching", 88 => "canoeing"
}.freeze
Definition = Struct.new(:global_mesg_num, :little_endian, :fields, :total_size)
ParseResult = Struct.new(:records, :activity_type)
def self.kinds(path)
to_geojson(path)["features"].empty? ? [] : ["track"]
@@ -29,16 +53,21 @@ module Maptool
end
def self.to_geojson(path)
records = parse(File.binread(path))
features = records.empty? ? [] : [track_feature(records.map(&:first), records.map(&:last))]
result = parse(File.binread(path))
features = result.records.empty? ? [] : [track_feature(result)]
{ "type" => "FeatureCollection", "features" => features }
end
def self.track_feature(coordinates, times)
def self.track_feature(result)
coordinates = result.records.map { |r| r[:coordinate] }
extras = result.records.map { |r| { speed_mps: r[:speed_mps], cadence_rpm: r[:cadence], heart_rate_bpm: r[:heart_rate] } }
{
"type" => "Feature",
"geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] },
"properties" => { "kind" => "track", "name" => nil }.merge(TrackStats.compute(coordinates, times: times)),
"properties" => { "kind" => "track", "name" => nil, "activity_type" => result.activity_type }
.merge(TrackStats.compute(coordinates, times: result.records.map { |r| r[:time] }))
.merge("profile" => TrackStats.build_profile(coordinates, extras: extras)),
}
end
private_class_method :track_feature
@@ -54,6 +83,7 @@ module Maptool
offset = header_size
definitions = {}
records = []
activity_type = nil
while offset < data_end
header = data.getbyte(offset)
@@ -62,7 +92,8 @@ module Maptool
if header & 0x80 != 0
local_type = (header >> 5) & 0x3
definition = definitions[local_type] or raise ParseError, "data message before its definition"
offset = read_data_message(data, offset, definition, records)
offset, sport = read_data_message(data, offset, definition, records)
activity_type ||= sport
elsif header & 0x40 != 0
local_type = header & 0x0F
definition, offset = read_definition(data, offset, header & 0x20 != 0)
@@ -70,11 +101,12 @@ module Maptool
else
local_type = header & 0x0F
definition = definitions[local_type] or raise ParseError, "data message before its definition"
offset = read_data_message(data, offset, definition, records)
offset, sport = read_data_message(data, offset, definition, records)
activity_type ||= sport
end
end
records
ParseResult.new(records, activity_type)
rescue ArgumentError, NoMethodError => e
raise ParseError, e.message
end
@@ -107,20 +139,38 @@ module Maptool
end
private_class_method :read_definition
# Returns [next_offset, sport_name_or_nil] — the latter only set when
# this message happens to be the one sport/session message carrying it.
def self.read_data_message(data, offset, definition, records)
sport = nil
if definition.global_mesg_num == RECORD_MESSAGE_NUM
record = extract_record(data, offset, definition)
records << record if record
elsif (field_num = SPORT_FIELD_BY_MESSAGE[definition.global_mesg_num])
sport = extract_sport(data, offset, definition, field_num)
end
offset + definition.total_size
[offset + definition.total_size, sport]
end
private_class_method :read_data_message
# Only reads the 4 fields the map/its properties need; every other
# field in the message (heart rate, cadence, power, ...) is walked past
# via its declared size without being interpreted.
def self.extract_sport(data, offset, definition, target_field_num)
pos = offset
definition.fields.each do |field_num, size|
if field_num == target_field_num
value = unpack_int(data, pos, size, definition.little_endian, signed: false)
return SPORT_NAMES[value]
end
pos += size
end
nil
end
private_class_method :extract_sport
# Only reads the fields the map/its properties need; every other field
# in the message (power, temperature, ...) is walked past via its
# declared size without being interpreted.
def self.extract_record(data, offset, definition)
lat = lon = altitude = timestamp = nil
lat = lon = altitude = timestamp = heart_rate = cadence = speed = nil
pos = offset
definition.fields.each do |field_num, size|
@@ -133,6 +183,12 @@ module Maptool
altitude ||= unpack_int(data, pos, size, definition.little_endian, signed: false)
when FIELD_TIMESTAMP
timestamp = unpack_int(data, pos, size, definition.little_endian, signed: false)
when FIELD_HEART_RATE
heart_rate = unpack_int(data, pos, size, definition.little_endian, signed: false)
when FIELD_CADENCE
cadence = unpack_int(data, pos, size, definition.little_endian, signed: false)
when FIELD_SPEED
speed = unpack_int(data, pos, size, definition.little_endian, signed: false)
end
pos += size
end
@@ -142,7 +198,11 @@ module Maptool
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
coordinate << (altitude / 5.0 - 500) if altitude && !invalid?(altitude, 2) && !invalid?(altitude, 4)
time = Time.at(FIT_EPOCH + timestamp).utc if timestamp && !invalid?(timestamp, 4)
[coordinate, time]
heart_rate = nil if heart_rate.nil? || invalid?(heart_rate, 1)
cadence = nil if cadence.nil? || invalid?(cadence, 1)
speed_mps = speed && !invalid?(speed, 2) ? speed / 1000.0 : nil
{ coordinate: coordinate, time: time, heart_rate: heart_rate, cadence: cadence, speed_mps: speed_mps }
end
private_class_method :extract_record
@@ -164,8 +224,8 @@ module Maptool
# FIT's per-base-type "field not present" sentinel: all bits set for an
# unsigned/positive-only reading, the same pattern for signed. Good
# enough for the two fields this cares about (position, altitude)
# without needing every base type's exact invalid value.
# enough for the fields this cares about without needing every base
# type's exact invalid value.
def self.invalid?(value, size)
value == (1 << (size * 8 - 1)) - 1 || value == -1 || value == (1 << (size * 8)) - 1
end
+23 -2
View File
@@ -54,6 +54,7 @@ module Maptool
lines = []
all_points = []
times = []
extras = []
trk.each_element("trkseg") do |seg|
points = []
seg.each_element("trkpt") do |pt|
@@ -61,6 +62,7 @@ module Maptool
points << point
all_points << point
times << parse_time(child_text(pt, "time"))
extras << point_extensions(pt)
end
lines << points unless points.empty?
end
@@ -68,12 +70,31 @@ module Maptool
{
"type" => "Feature",
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
"properties" => { "kind" => "track", "name" => child_text(trk, "name") }
.merge(TrackStats.compute(all_points, times: times)),
"properties" => { "kind" => "track", "name" => child_text(trk, "name"), "activity_type" => child_text(trk, "type") }
.merge(TrackStats.compute(all_points, times: times))
.merge("profile" => TrackStats.build_profile(all_points, extras: extras)),
}
end
private_class_method :track_feature
# Heart rate/cadence/speed live in a device-specific <extensions> block
# (usually Garmin's own TrackPointExtension) — matched by local element
# name only (`Element#name` already strips the namespace prefix), so
# this doesn't care which extension schema a given file actually
# declares, as long as it names its elements the same way Garmin's does.
def self.point_extensions(point_el)
values = {}
point_el.elements.each("extensions//*") do |el|
case el.name
when "hr" then values[:heart_rate_bpm] = el.text&.strip&.to_i
when "cad" then values[:cadence_rpm] = el.text&.strip&.to_i
when "speed" then values[:speed_mps] = el.text&.strip&.to_f
end
end
values
end
private_class_method :point_extensions
def self.parse_time(text)
text && Time.parse(text)
rescue ArgumentError
+27
View File
@@ -27,6 +27,33 @@ module Maptool
}
end
# One entry per coordinate, for the profile chart: cumulative distance
# (computed the same way for every format, rather than trusting a
# device's own odometer field — keeps a GPX and a FIT file directly
# comparable), elevation, and whatever per-point extras (speed/cadence/
# heart rate) the caller has, nil where a format/file doesn't carry it.
# `extras`: array of hashes parallel to `coordinates`, or shorter/empty.
def self.build_profile(coordinates, extras: [])
distance = 0.0
previous = nil
coordinates.map.with_index do |(lon, lat, ele), i|
distance += haversine(previous[1], previous[0], lat, lon) if previous
previous = [lon, lat]
extra = extras[i] || {}
{
"distance_meters" => distance.round(1),
"elevation_meters" => ele,
"speed_mps" => extra[:speed_mps],
"cadence_rpm" => extra[:cadence_rpm],
"heart_rate_bpm" => extra[:heart_rate_bpm],
"lon" => lon,
"lat" => lat,
}
end
end
def self.haversine(lat1, lon1, lat2, lon2)
rad = Math::PI / 180
dlat = (lat2 - lat1) * rad
+112
View File
@@ -504,3 +504,115 @@ html, body {
color: #888;
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 { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { formatActivityType } from "/js/track_profile.js";
const TREE_URL = "/api/gpx/tree";
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
// off the map.
export class GpxPanelControl {
constructor(map) {
constructor(map, profilePanel) {
this._map = map;
this._profilePanel = profilePanel;
this._visible = new Set(); // file paths currently shown on the map
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
// (distance, elevation gain, duration) describe the whole line, not
// whatever point along it happens to get clicked.
// Hover shows a quick summary (unlike a waypoint popup, which is
// click-triggered — a track's summary describes the whole line, not
// 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) {
if (this._interactiveLayers.has(layerId)) return;
this._interactiveLayers.add(layerId);
@@ -353,6 +358,10 @@ export class GpxPanelControl {
popup?.remove();
popup = null;
});
this._map.on("click", layerId, (event) => {
const feature = event.features[0];
if (feature) this._profilePanel?.show(feature.properties);
});
}
_removeLayers(path) {
@@ -488,15 +497,17 @@ function popupHtml({ name, description, time }) {
}
// 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 = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
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 elevation_gain_meters === "number") stats.push(`↑ ${Math.round(elevation_gain_meters)} m`);
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
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>";
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { RadarControl } from "/js/radar.js";
import { GpxPanelControl } from "/js/gpx.js";
import { GnssControl } from "/js/gnss.js";
import { LiveTrackPanelControl } from "/js/livetrack.js";
import { TrackProfilePanel } from "/js/track_profile.js";
import { LAYERS_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
@@ -125,7 +126,9 @@ const layerToggle = new LayerToggleControl(applyStyle);
layerToggle.mount(sidebar);
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 GnssControl(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>
<trk>
<name>Morning Loop</name>
<type>running</type>
<trkseg>
<trkpt lat="48.1" lon="17.2">
<ele>200</ele>
<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 lat="48.101" lon="17.201">
<ele>250</ele>
<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 lat="48.102" lon="17.202">
<ele>230</ele>
+64
View File
@@ -57,4 +57,68 @@ class FitTest < Minitest::Test
assert_raises(Maptool::Fit::ParseError) { Maptool::Fit.to_geojson(path) }
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
+13
View File
@@ -23,11 +23,24 @@ class GpxTest < Minitest::Test
assert_equal "MultiLineString", track["geometry"]["type"]
assert_equal 3, track["geometry"]["coordinates"][0].size
assert_equal "Morning Loop", track["properties"]["name"]
assert_equal "running", track["properties"]["activity_type"]
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 1200, track["properties"]["duration_secs"]
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
path = File.join(Dir.mktmpdir, "bad.gpx")
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]])
assert_equal 0.0, stats["distance_meters"]
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
+9
View File
@@ -17,6 +17,15 @@
</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 class="basemap-panel__header">
<strong>Base map</strong>
+2 -1
View File
@@ -25,7 +25,8 @@
"/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>",
"/js/livetrack.js": "/js/livetrack.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>