Add a Distance/Time toggle to the profile chart's x-axis
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 16s

TrackStats.build_profile now also computes elapsed_secs per point
(relative to the first known timestamp), alongside the existing
cumulative distance. Point x-positions stay distance-based either
way — only the shared axis strip's 5 tick labels (and the tooltip)
switch between the two, so toggling is a cheap re-render rather than
rebuilding the charts. The toggle only appears when a track actually
has elapsed-time data to switch to.
This commit is contained in:
randy committed 2026-09-07 13:54:49 +02:00
1 parent 4f7fe3c2a4
commit a0476f1204
7 files changed
+138 -14

No files matched your search

+19 -8
View File
@@ -265,14 +265,16 @@ when present. `Maptool::TrackStats.build_profile` (called from both
`Gpx`/`Fit`, alongside `.compute` above) produces this: one entry per `Gpx`/`Fit`, alongside `.compute` above) produces this: one entry per
point with cumulative distance (computed the same way for every 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 format, not trusted from a device's own odometer field, so a GPX and a
FIT track are directly comparable), elevation, and whatever FIT track are directly comparable), elapsed time since the first known
speed/cadence/heart-rate extras that point's format/file carried — timestamp, elevation, and whatever speed/cadence/heart-rate extras
`nil` fields simply don't render their chart. For FIT, those extras that point's format/file carried — `nil` fields simply don't render
are the `record` message's own `heart_rate`/`cadence`/`speed` fields their chart (or, for `elapsed_secs`, don't offer the axis toggle
(3/4/6); for GPX, a device-specific `<extensions>` block under each below). For FIT, those extras are the `record` message's own
`<trkpt>` — usually Garmin's `TrackPointExtension`, matched by local `heart_rate`/`cadence`/`speed` fields (3/4/6); for GPX, a
element name only (`hr`/`cad`/`speed`) so it doesn't care which XML device-specific `<extensions>` block under each `<trkpt>` — usually
namespace prefix a given file happens to declare. 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/ Each chart is its own single-series `<svg>` (elevation/speed/cadence/
heart rate are on wildly different scales, so this is small multiples heart rate are on wildly different scales, so this is small multiples
@@ -288,6 +290,15 @@ three things at once: a synchronized crosshair line in every chart, a
single tooltip listing every active series' value at that point, and 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 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. hovering the chart shows where on the track that moment actually was.
Each chart's own min/max are shown as plain HTML overlays in its
top-left/bottom-left corners (not SVG `<text>` — the viewBox's
non-uniform scaling would stretch it). Below the stack, a single
shared x-axis strip (again HTML, same reason) shows 5 evenly-spaced
ticks along the shared distance scale; a Distance/Time toggle above it
(only shown when the track has any elapsed-time data at all) switches
what those 5 ticks are labeled with — the underlying point positions
stay distance-based either way, only the tick text changes, so
toggling is a cheap re-render of just that strip.
== Live GNSS position == Live GNSS position
+1 -1
View File
@@ -67,7 +67,7 @@ module Maptool
"geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] }, "geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] },
"properties" => { "kind" => "track", "name" => nil, "activity_type" => result.activity_type } "properties" => { "kind" => "track", "name" => nil, "activity_type" => result.activity_type }
.merge(TrackStats.compute(coordinates, times: result.records.map { |r| r[:time] })) .merge(TrackStats.compute(coordinates, times: result.records.map { |r| r[:time] }))
.merge("profile" => TrackStats.build_profile(coordinates, extras: extras)), .merge("profile" => TrackStats.build_profile(coordinates, times: result.records.map { |r| r[:time] }, extras: extras)),
} }
end end
private_class_method :track_feature private_class_method :track_feature
+1 -1
View File
@@ -72,7 +72,7 @@ module Maptool
"geometry" => { "type" => "MultiLineString", "coordinates" => lines }, "geometry" => { "type" => "MultiLineString", "coordinates" => lines },
"properties" => { "kind" => "track", "name" => child_text(trk, "name"), "activity_type" => child_text(trk, "type") } "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)), .merge("profile" => TrackStats.build_profile(all_points, times: times, extras: extras)),
} }
end end
private_class_method :track_feature private_class_method :track_feature
+8 -3
View File
@@ -30,10 +30,13 @@ module Maptool
# One entry per coordinate, for the profile chart: cumulative distance # One entry per coordinate, for the profile chart: cumulative distance
# (computed the same way for every format, rather than trusting a # (computed the same way for every format, rather than trusting a
# device's own odometer field — keeps a GPX and a FIT file directly # device's own odometer field — keeps a GPX and a FIT file directly
# comparable), elevation, and whatever per-point extras (speed/cadence/ # comparable), elapsed time since the first known timestamp, elevation,
# heart rate) the caller has, nil where a format/file doesn't carry it. # and whatever per-point extras (speed/cadence/heart rate) the caller
# has — nil wherever a format/file doesn't carry it.
# `extras`: array of hashes parallel to `coordinates`, or shorter/empty. # `extras`: array of hashes parallel to `coordinates`, or shorter/empty.
def self.build_profile(coordinates, extras: []) # `times`: array of Time (or nil) parallel to `coordinates`, or [].
def self.build_profile(coordinates, times: [], extras: [])
start_time = times.compact.first
distance = 0.0 distance = 0.0
previous = nil previous = nil
@@ -41,9 +44,11 @@ module Maptool
distance += haversine(previous[1], previous[0], lat, lon) if previous distance += haversine(previous[1], previous[0], lat, lon) if previous
previous = [lon, lat] previous = [lon, lat]
extra = extras[i] || {} extra = extras[i] || {}
point_time = times[i]
{ {
"distance_meters" => distance.round(1), "distance_meters" => distance.round(1),
"elapsed_secs" => point_time && start_time ? (point_time - start_time).to_i : nil,
"elevation_meters" => ele, "elevation_meters" => ele,
"speed_mps" => extra[:speed_mps], "speed_mps" => extra[:speed_mps],
"cadence_rpm" => extra[:cadence_rpm], "cadence_rpm" => extra[:cadence_rpm],
+46
View File
@@ -619,6 +619,52 @@ html, body {
bottom: 2px; bottom: 2px;
} }
.profile-panel__xaxis-toggle {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-bottom: 4px;
}
.profile-panel__xaxis-toggle-btn {
border: 1px solid #e1e0d9;
background: #fcfcfb;
color: #52514e;
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
}
.profile-panel__xaxis-toggle-btn.active {
background: #0b0b0b;
border-color: #0b0b0b;
color: #fff;
}
.profile-panel__xaxis {
position: relative;
height: 14px;
margin-top: 6px;
}
.profile-panel__xaxis-tick {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 10px;
color: #898781;
white-space: nowrap;
}
.profile-panel__xaxis-tick:first-child {
transform: translateX(0);
}
.profile-panel__xaxis-tick:last-child {
transform: translateX(-100%);
}
.profile-panel__tooltip { .profile-panel__tooltip {
position: absolute; position: absolute;
top: 0; top: 0;
+47 -1
View File
@@ -55,9 +55,23 @@ export class TrackProfilePanel {
this._tooltip.hidden = true; this._tooltip.hidden = true;
this._chartsContainer.appendChild(this._tooltip); this._chartsContainer.appendChild(this._tooltip);
// Only offer the toggle when there's actually elapsed-time data to
// switch to — plenty of GPX files have no <time> at all.
this._hasTime = this._profile.some((p) => typeof p.elapsed_secs === "number");
this._xAxisMode = "distance";
if (this._hasTime) this._chartsContainer.appendChild(this._renderXAxisToggle());
this._activeSeries = SERIES.filter((s) => this._profile.some((p) => typeof p[s.key] === "number")); 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._activeSeries.forEach((series) => this._chartsContainer.appendChild(this._renderChart(series)));
// One shared strip below the whole stack, not per-chart — every chart
// above uses the same x-scale (distance-position), so a separate axis
// per chart would just repeat itself.
this._xAxisStripEl = document.createElement("div");
this._xAxisStripEl.className = "profile-panel__xaxis";
this._chartsContainer.appendChild(this._xAxisStripEl);
this._renderXAxisTicks();
this._chartsContainer.addEventListener("pointermove", this._onPointerMove); this._chartsContainer.addEventListener("pointermove", this._onPointerMove);
this._chartsContainer.addEventListener("pointerleave", this._onPointerLeave); this._chartsContainer.addEventListener("pointerleave", this._onPointerLeave);
@@ -108,6 +122,37 @@ export class TrackProfilePanel {
return wrapper; return wrapper;
} }
_renderXAxisToggle() {
const row = document.createElement("div");
row.className = "profile-panel__xaxis-toggle";
row.innerHTML = `
<button type="button" class="profile-panel__xaxis-toggle-btn" data-mode="distance">Distance</button>
<button type="button" class="profile-panel__xaxis-toggle-btn" data-mode="time">Time</button>
`;
row.querySelectorAll("button").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.mode === this._xAxisMode);
btn.addEventListener("click", () => {
if (btn.dataset.mode === this._xAxisMode) return;
this._xAxisMode = btn.dataset.mode;
row.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
this._renderXAxisTicks();
});
});
return row;
}
_renderXAxisTicks() {
const TICK_COUNT = 5;
this._xAxisStripEl.innerHTML = Array.from({ length: TICK_COUNT }, (_, i) => {
const fraction = i / (TICK_COUNT - 1);
const point = this._profile[nearestIndex(this._profile, fraction * this._maxDistance)];
const label = this._xAxisMode === "time" && typeof point.elapsed_secs === "number"
? formatDuration(point.elapsed_secs)
: `${(point.distance_meters / 1000).toFixed(1)} km`;
return `<span class="profile-panel__xaxis-tick" style="left:${(fraction * 100).toFixed(2)}%">${escapeHtml(label)}</span>`;
}).join("");
}
// Bound once (arrow fields, not prototype methods) so add/removeEventListener see the same reference. // Bound once (arrow fields, not prototype methods) so add/removeEventListener see the same reference.
_onPointerMove = (event) => { _onPointerMove = (event) => {
const rect = this._chartsContainer.getBoundingClientRect(); const rect = this._chartsContainer.getBoundingClientRect();
@@ -158,7 +203,8 @@ export class TrackProfilePanel {
.filter((s) => typeof point[s.key] === "number") .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>`) .map((s) => `<div><span class="profile-panel__tooltip-label">${escapeHtml(s.label)}</span> <strong>${formatValue(point[s.key], s)}</strong></div>`)
.join(""); .join("");
this._tooltip.innerHTML = `<div class="profile-panel__tooltip-distance">${(point.distance_meters / 1000).toFixed(2)} km</div>${rows}`; const timeSuffix = typeof point.elapsed_secs === "number" ? ` · ${formatDuration(point.elapsed_secs)}` : "";
this._tooltip.innerHTML = `<div class="profile-panel__tooltip-distance">${(point.distance_meters / 1000).toFixed(2)} km${timeSuffix}</div>${rows}`;
this._tooltip.hidden = false; this._tooltip.hidden = false;
const containerRect = this._chartsContainer.getBoundingClientRect(); const containerRect = this._chartsContainer.getBoundingClientRect();
+16
View File
@@ -54,4 +54,20 @@ class TrackStatsTest < Minitest::Test
assert_nil profile[0]["heart_rate_bpm"] assert_nil profile[0]["heart_rate_bpm"]
assert_nil profile[1]["speed_mps"] assert_nil profile[1]["speed_mps"]
end end
def test_build_profile_elapsed_secs_is_relative_to_the_first_known_timestamp
points = [[17.0, 48.0], [17.001, 48.0], [17.002, 48.0]]
times = [nil, Time.utc(2026, 1, 1, 0, 0, 0), Time.utc(2026, 1, 1, 0, 5, 0)]
profile = Maptool::TrackStats.build_profile(points, times: times)
assert_nil profile[0]["elapsed_secs"], "no timestamp yet at the first point"
assert_equal 0, profile[1]["elapsed_secs"], "first known timestamp is the zero point"
assert_equal 300, profile[2]["elapsed_secs"]
end
def test_build_profile_elapsed_secs_is_nil_without_any_times
profile = Maptool::TrackStats.build_profile([[17.0, 48.0]], times: [])
assert_nil profile[0]["elapsed_secs"]
end
end end