Add a Distance/Time toggle to the profile chart's x-axis
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:
1 parent
4f7fe3c2a4
commit
a0476f1204
7 files changed
+138
-14
No files matched your search
+19
-8
@@ -265,14 +265,16 @@ 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.
|
||||
FIT track are directly comparable), elapsed time since the first known
|
||||
timestamp, elevation, and whatever speed/cadence/heart-rate extras
|
||||
that point's format/file carried — `nil` fields simply don't render
|
||||
their chart (or, for `elapsed_secs`, don't offer the axis toggle
|
||||
below). 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
|
||||
@@ -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
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ module Maptool
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] },
|
||||
"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)),
|
||||
.merge("profile" => TrackStats.build_profile(coordinates, times: result.records.map { |r| r[:time] }, extras: extras)),
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ module Maptool
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
||||
"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)),
|
||||
.merge("profile" => TrackStats.build_profile(all_points, times: times, extras: extras)),
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
|
||||
@@ -30,10 +30,13 @@ module Maptool
|
||||
# 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.
|
||||
# comparable), elapsed time since the first known timestamp, elevation,
|
||||
# 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.
|
||||
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
|
||||
previous = nil
|
||||
|
||||
@@ -41,9 +44,11 @@ module Maptool
|
||||
distance += haversine(previous[1], previous[0], lat, lon) if previous
|
||||
previous = [lon, lat]
|
||||
extra = extras[i] || {}
|
||||
point_time = times[i]
|
||||
|
||||
{
|
||||
"distance_meters" => distance.round(1),
|
||||
"elapsed_secs" => point_time && start_time ? (point_time - start_time).to_i : nil,
|
||||
"elevation_meters" => ele,
|
||||
"speed_mps" => extra[:speed_mps],
|
||||
"cadence_rpm" => extra[:cadence_rpm],
|
||||
|
||||
@@ -619,6 +619,52 @@ html, body {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -55,9 +55,23 @@ export class TrackProfilePanel {
|
||||
this._tooltip.hidden = true;
|
||||
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.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("pointerleave", this._onPointerLeave);
|
||||
|
||||
@@ -108,6 +122,37 @@ export class TrackProfilePanel {
|
||||
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.
|
||||
_onPointerMove = (event) => {
|
||||
const rect = this._chartsContainer.getBoundingClientRect();
|
||||
@@ -158,7 +203,8 @@ export class TrackProfilePanel {
|
||||
.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}`;
|
||||
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;
|
||||
|
||||
const containerRect = this._chartsContainer.getBoundingClientRect();
|
||||
|
||||
@@ -54,4 +54,20 @@ class TrackStatsTest < Minitest::Test
|
||||
assert_nil profile[0]["heart_rate_bpm"]
|
||||
assert_nil profile[1]["speed_mps"]
|
||||
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
|
||||
Reference in new issue
Block a user