From a0476f1204e65061c2c3a051b12a2c35d98d4ac9 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Mon, 7 Sep 2026 13:54:49 +0200 Subject: [PATCH] Add a Distance/Time toggle to the profile chart's x-axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/architecture.adoc | 27 ++++++++++++++------- lib/maptool/fit.rb | 2 +- lib/maptool/gpx.rb | 2 +- lib/maptool/track_stats.rb | 11 ++++++--- public/css/style.css | 46 ++++++++++++++++++++++++++++++++++++ public/js/track_profile.js | 48 +++++++++++++++++++++++++++++++++++++- test/test_track_stats.rb | 16 +++++++++++++ 7 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/architecture.adoc b/docs/architecture.adoc index de48cbe..92ae475 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -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 `` block under each -`` — 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 `` block under each `` — 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 `` (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 `` — 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 diff --git a/lib/maptool/fit.rb b/lib/maptool/fit.rb index 4fc80b4..26ced28 100644 --- a/lib/maptool/fit.rb +++ b/lib/maptool/fit.rb @@ -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 diff --git a/lib/maptool/gpx.rb b/lib/maptool/gpx.rb index 80c1381..b7aa293 100644 --- a/lib/maptool/gpx.rb +++ b/lib/maptool/gpx.rb @@ -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 diff --git a/lib/maptool/track_stats.rb b/lib/maptool/track_stats.rb index abd09fd..8f05d7d 100644 --- a/lib/maptool/track_stats.rb +++ b/lib/maptool/track_stats.rb @@ -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], diff --git a/public/css/style.css b/public/css/style.css index 628e022..bd6203e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; diff --git a/public/js/track_profile.js b/public/js/track_profile.js index 3e57134..c2fcc9d 100644 --- a/public/js/track_profile.js +++ b/public/js/track_profile.js @@ -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 ${escapeHtml(label)}`; + }).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) => `
${escapeHtml(s.label)} ${formatValue(point[s.key], s)}
`) .join(""); - this._tooltip.innerHTML = `
${(point.distance_meters / 1000).toFixed(2)} km
${rows}`; + const timeSuffix = typeof point.elapsed_secs === "number" ? ` · ${formatDuration(point.elapsed_secs)}` : ""; + this._tooltip.innerHTML = `
${(point.distance_meters / 1000).toFixed(2)} km${timeSuffix}
${rows}`; this._tooltip.hidden = false; const containerRect = this._chartsContainer.getBoundingClientRect(); diff --git a/test/test_track_stats.rb b/test/test_track_stats.rb index 7750272..e4c618e 100644 --- a/test/test_track_stats.rb +++ b/test/test_track_stats.rb @@ -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