From 32f6dca86de0b601e5ea36594122ba3c1d8e7f50 Mon Sep 17 00:00:00 2001 From: Kristian Karl Date: Mon, 3 Aug 2026 18:31:42 +0200 Subject: [PATCH 1/2] feat: allow distance-based x-axis for elevation charts Add an optional `x_axis: "distance"` setting on plot elements so the elevation chart (and other non-course scalar plots) can plot against distance travelled instead of evenly spaced samples (time at constant frame rate). Exposed as a toggle under the chart's Advanced panel. Default remains the time/sample-index axis; course and distance plots are unaffected. --- .../panels/ElementProperties.svelte | 17 ++++++ src-tauri/render-core/src/activity.rs | 52 +++++++++++++++++-- src-tauri/render-core/src/frame.rs | 4 +- src-tauri/render-core/src/template.rs | 4 ++ 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/app/src/components/panels/ElementProperties.svelte b/app/src/components/panels/ElementProperties.svelte index 23de9b7..f6c98ef 100644 --- a/app/src/components/panels/ElementProperties.svelte +++ b/app/src/components/panels/ElementProperties.svelte @@ -1494,6 +1494,23 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual {#if showAdvanced} + {#if item.value === 'elevation'} + +
+

X-Axis

+ +

+ Off: evenly spaced by time. On: horizontal position reflects distance travelled. +

+
+ {/if}

Fill

diff --git a/src-tauri/render-core/src/activity.rs b/src-tauri/render-core/src/activity.rs index 4122523..5dc9474 100644 --- a/src-tauri/render-core/src/activity.rs +++ b/src-tauri/render-core/src/activity.rs @@ -1581,10 +1581,18 @@ impl Activity { } /// Build (x, y) data arrays for a plot of the given attribute. - pub fn plot_data(&self, attribute: &str) -> (Vec, Vec) { + /// `x_axis` only affects non-course scalar plots: "distance" uses the + /// travelled distance as the horizontal axis, anything else uses evenly + /// spaced sample indices (i.e. time at constant frame rate). + pub fn plot_data(&self, attribute: &str, x_axis: Option<&str>) -> (Vec, Vec) { + let distance_based = x_axis == Some("distance"); let scalar = |data: &[f64]| -> (Vec, Vec) { - let x: Vec = (0..data.len()).map(|i| i as f64).collect(); - (x, data.to_vec()) + if distance_based && attribute != ATTR_DISTANCE && data.len() == self.distance.len() { + (self.distance.clone(), data.to_vec()) + } else { + let x: Vec = (0..data.len()).map(|i| i as f64).collect(); + (x, data.to_vec()) + } }; match attribute { ATTR_DISTANCE => scalar(&self.distance), @@ -1962,7 +1970,7 @@ mod tests { assert!(out.route_distance.windows(2).all(|w| w[1] >= w[0])); // Course plots draw from `route`, so the plotted line stays full-density. - let (x, y) = out.plot_data(ATTR_COURSE); + let (x, y) = out.plot_data(ATTR_COURSE, None); assert_eq!(x.len(), 600); assert_eq!(y.len(), 600); } @@ -2637,6 +2645,42 @@ mod tests { // Unknown running token resolves to 0. assert_eq!(out.get_running("not_a_metric", mid), 0.0); } + + #[test] + fn plot_data_uses_distance_x_axis_when_requested() { + let mut activity = Activity::default(); + activity.distance = vec![0.0, 5.0, 15.0]; + activity.elevation = vec![10.0, 20.0, 10.0]; + activity.speed = vec![1.0, 2.0, 3.0]; + activity.course = vec![(0.0, 0.0), (0.0, 1.0), (0.0, 2.0)]; + activity.route = vec![(0.0, 0.0), (0.0, 1.0), (0.0, 2.0)]; + activity.valid_attributes = vec![ + ATTR_DISTANCE.to_string(), + ATTR_ELEVATION.to_string(), + ATTR_SPEED.to_string(), + ATTR_COURSE.to_string(), + ]; + + // Default (time/sample-index) x-axis. + let (x, y) = activity.plot_data(ATTR_ELEVATION, None); + assert_eq!(x, vec![0.0, 1.0, 2.0]); + assert_eq!(y, vec![10.0, 20.0, 10.0]); + + // Distance-based x-axis: x mirrors the distance array. + let (x, y) = activity.plot_data(ATTR_ELEVATION, Some("distance")); + assert_eq!(x, vec![0.0, 5.0, 15.0]); + assert_eq!(y, vec![10.0, 20.0, 10.0]); + + // Course plots ignore x_axis (keep geographic lon/lat). + let (x, y) = activity.plot_data(ATTR_COURSE, Some("distance")); + assert_eq!(x, vec![0.0, 1.0, 2.0]); + assert_eq!(y, vec![0.0, 0.0, 0.0]); + + // Distance plot never becomes a diagonal line: falls back to indices. + let (x, y) = activity.plot_data(ATTR_DISTANCE, Some("distance")); + assert_eq!(x, vec![0.0, 1.0, 2.0]); + assert_eq!(y, vec![0.0, 5.0, 15.0]); + } } // ─── XML helpers ────────────────────────────────────────────────────────── diff --git a/src-tauri/render-core/src/frame.rs b/src-tauri/render-core/src/frame.rs index f5ab49a..5599b66 100644 --- a/src-tauri/render-core/src/frame.rs +++ b/src-tauri/render-core/src/frame.rs @@ -375,7 +375,7 @@ fn resample_by_distance(series: &[f64], src_dist: &[f64], dst_dist: &[f64]) -> V impl OverlayElement for PlotConfig { fn build_chart(&self, activity: &Activity, fonts_dir: &str) -> Option { - let (x_data, y_data) = activity.plot_data(&self.value); + let (x_data, y_data) = activity.plot_data(&self.value, self.x_axis.as_deref()); let is_course = self.value == crate::activity::ATTR_COURSE; // `distance_data` is the distance axis of the plotted geometry, so for a // course it must align with the source-density route, not the frame grid. @@ -393,7 +393,7 @@ impl OverlayElement for PlotConfig { .color_by .as_ref() .map(|cb| { - let series = activity.plot_data(cb.attr()).1; + let series = activity.plot_data(cb.attr(), None).1; if is_course { resample_by_distance(&series, &activity.distance, &activity.route_distance) } else { diff --git a/src-tauri/render-core/src/template.rs b/src-tauri/render-core/src/template.rs index 75e578f..40691f0 100644 --- a/src-tauri/render-core/src/template.rs +++ b/src-tauri/render-core/src/template.rs @@ -430,6 +430,10 @@ pub struct PlotConfig { pub markers: Option>, pub point_label: Option, pub rotation: Option, + /// X-axis basis for non-course scalar plots: "time" (default; evenly spaced + /// samples) or "distance" (horizontal position = distance travelled). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x_axis: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] From c6ad397e7c5d79ad0596d03089b8bef53008e7eb Mon Sep 17 00:00:00 2001 From: Walker Sutton Date: Mon, 31 Aug 2026 12:07:36 -0400 Subject: [PATCH 2/2] fix: harden distance x-axis for banding, flat rides, and metric switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the distance-based x-axis: - Sample the banded fill across the x range rather than the sample index when x is monotonic. Under a distance axis the two diverge: index stepping spends most columns on the dense slow sections and leaves the wide fast ones with a handful of vertices, quantising band edges to ~100px. Course plots keep index stepping — their x is longitude and doubles back. - Fall back to sample indices when the distance array has no span (indoor rides, tracks with repeated coords), which would otherwise collapse every point onto the left edge of the plot. - Offer the toggle on every non-course, non-distance plot instead of elevation alone. The backend already applied x_axis to any scalar, so switching an elevation plot to speed left the setting applied with no UI to see or clear it. - Match the established switch-row tokens, and document x_axis on the PlotElement typedef. Co-Authored-By: Claude Opus 5 --- .../panels/ElementProperties.svelte | 32 ++++----- app/src/lib/elementTypes.js | 1 + src-tauri/render-core/src/activity.rs | 24 ++++++- src-tauri/render-core/src/chart.rs | 70 ++++++++++++++++++- 4 files changed, 107 insertions(+), 20 deletions(-) diff --git a/app/src/components/panels/ElementProperties.svelte b/app/src/components/panels/ElementProperties.svelte index f6c98ef..036fe8d 100644 --- a/app/src/components/panels/ElementProperties.svelte +++ b/app/src/components/panels/ElementProperties.svelte @@ -1494,23 +1494,21 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
{#if showAdvanced} - {#if item.value === 'elevation'} - -
-

X-Axis

- -

- Off: evenly spaced by time. On: horizontal position reflects distance travelled. -

-
- {/if} + + {#if item.value !== 'course' && item.value !== 'distance'} +
+

X-Axis

+ +

+ Off: evenly spaced by time. On: horizontal position reflects distance travelled. +

+
+ {/if}

Fill

diff --git a/app/src/lib/elementTypes.js b/app/src/lib/elementTypes.js index 54a3c19..57e7189 100644 --- a/app/src/lib/elementTypes.js +++ b/app/src/lib/elementTypes.js @@ -195,6 +195,7 @@ export const isLapMetric = (m) => LAP_METRICS.includes(m) * @property {CourseMarkerConfig[]} [markers] * @property {PointLabelConfig} [point_label] * @property {number} [rotation] + * @property {'distance'} [x_axis] Horizontal axis basis for non-course plots; absent = evenly spaced by time. */ // Mirrors DEFAULT_GRADIENT_BANDS in src-tauri/src/render/template.rs: descent, diff --git a/src-tauri/render-core/src/activity.rs b/src-tauri/render-core/src/activity.rs index 5dc9474..1b586e6 100644 --- a/src-tauri/render-core/src/activity.rs +++ b/src-tauri/render-core/src/activity.rs @@ -1585,7 +1585,15 @@ impl Activity { /// travelled distance as the horizontal axis, anything else uses evenly /// spaced sample indices (i.e. time at constant frame rate). pub fn plot_data(&self, attribute: &str, x_axis: Option<&str>) -> (Vec, Vec) { - let distance_based = x_axis == Some("distance"); + // A distance axis needs an actual span to map onto: a stationary ride + // (indoor, or a track with repeated coords) yields a flat array that + // would collapse every point onto the left edge, so fall back to + // sample indices. `distance` is monotonic, so the ends bound the span. + let distance_spans = matches!( + (self.distance.first(), self.distance.last()), + (Some(first), Some(last)) if last > first + ); + let distance_based = x_axis == Some("distance") && distance_spans; let scalar = |data: &[f64]| -> (Vec, Vec) { if distance_based && attribute != ATTR_DISTANCE && data.len() == self.distance.len() { (self.distance.clone(), data.to_vec()) @@ -2681,6 +2689,20 @@ mod tests { assert_eq!(x, vec![0.0, 1.0, 2.0]); assert_eq!(y, vec![0.0, 5.0, 15.0]); } + + #[test] + fn plot_data_falls_back_to_indices_when_distance_is_flat() { + // A stationary ride has no distance span to map onto — using it as the + // axis would collapse the whole plot onto the left edge. + let mut activity = Activity::default(); + activity.distance = vec![0.0, 0.0, 0.0]; + activity.elevation = vec![10.0, 20.0, 10.0]; + activity.valid_attributes = vec![ATTR_DISTANCE.to_string(), ATTR_ELEVATION.to_string()]; + + let (x, y) = activity.plot_data(ATTR_ELEVATION, Some("distance")); + assert_eq!(x, vec![0.0, 1.0, 2.0]); + assert_eq!(y, vec![10.0, 20.0, 10.0]); + } } // ─── XML helpers ────────────────────────────────────────────────────────── diff --git a/src-tauri/render-core/src/chart.rs b/src-tauri/render-core/src/chart.rs index bc86512..3562732 100644 --- a/src-tauri/render-core/src/chart.rs +++ b/src-tauri/render-core/src/chart.rs @@ -816,11 +816,37 @@ fn draw_banded_fill( } let base_y = to_px(*x_data.first()?, y_base).y; + // Map a pixel column to a fractional position along the series. Under a + // uniform x-axis index and pixel space agree, but a distance-based axis is + // non-uniform: stepping by index would under-sample fast descents (chunky + // fill, coarse band edges) and pile many samples into one column wherever + // the rider was stopped. Walk the x range instead whenever x is monotonic; + // a course's x is longitude and doubles back, so it keeps index stepping. + let monotonic_x = x_data.windows(2).all(|w| w[1] >= w[0]); + let x_first = *x_data.first()?; + let span_x = *x_data.last()? - x_first; + let position_at = |c: i32| -> f64 { + let frac = c as f64 / cols_n as f64; + if !monotonic_x || span_x <= 0.0 { + return frac * (n - 1) as f64; + } + let target = x_first + span_x * frac; + let j = x_data.partition_point(|&x| x < target).clamp(1, n - 1); + let i = j - 1; + let (x0, x1) = (x_data[i], x_data[j]); + i as f64 + + if x1 > x0 { + (target - x0) / (x1 - x0) + } else { + 0.0 + } + }; + // Per pixel column: (x px, curve y px, band index). let cols: Vec<(f32, f32, usize)> = (0..=cols_n) .map(|c| { - let pos = c as f64 / cols_n as f64 * (n - 1) as f64; - let i = pos.floor() as usize; + let pos = position_at(c); + let i = (pos.floor() as usize).min(n - 1); let j = (i + 1).min(n - 1); let f = pos - i as f64; let x = x_data[i] + (x_data[j] - x_data[i]) * f; @@ -992,6 +1018,46 @@ mod tests { assert_eq!(pixel(&cache.background, 100, 10), (0, 0, 0, 0)); } + #[test] + fn banded_fill_resolves_band_edges_on_a_non_uniform_x_axis() { + // Distance-axis case: 1980 dense samples crammed into the leftmost 1% + // of the width (a slow climb), then 20 sparse samples covering the + // remaining 99% (a fast descent). Sampling the fill by index would + // spend 396 of 400 columns on those first 4 pixels and leave the band + // edge in the sparse half quantised to the nearest ~100px column. + let config: crate::template::PlotConfig = serde_json::from_value(serde_json::json!({ + "id": "plot-0", + "type": "plot", + "value": "elevation", + "x": 0, "y": 0, "width": 400, "height": 150, + "color_by": { "value": "gradient" } + })) + .unwrap(); + let n = 2000; + let dense = 1980; + let x: Vec = (0..n) + .map(|i| { + if i < dense { + i as f64 / dense as f64 * 10.0 + } else { + 10.0 + (i - dense) as f64 / (n - dense) as f64 * 990.0 + } + }) + .collect(); + let y: Vec = (0..n).map(|i| i as f64 / n as f64 * 100.0).collect(); + // Band flips green → orange at index 1988, i.e. x = 406 → pixel 162. + let grade: Vec = (0..n).map(|i| if i < 1988 { 2.0 } else { 8.0 }).collect(); + let cache = ChartCache::build(&config, x, y, Vec::new(), Vec::new(), grade, "/nonexistent") + .unwrap(); + assert!(cache.banding.is_some()); + + // Deep under the curve either side of the true edge. Index sampling + // would colour pixel 130 orange, having last sampled the series at + // pixel 103 and next at 202. + assert_eq!(pixel(&cache.background, 130, 140), (0x5e, 0xc5, 0x22, 0xff)); + assert_eq!(pixel(&cache.background, 250, 140), (0x16, 0x73, 0xf9, 0xff)); + } + #[test] fn banded_polyline_strokes_runs_and_applies_layer_alpha() { let info = ImageInfo::new(