diff --git a/app/src/components/panels/ElementProperties.svelte b/app/src/components/panels/ElementProperties.svelte index 23de9b7..036fe8d 100644 --- a/app/src/components/panels/ElementProperties.svelte +++ b/app/src/components/panels/ElementProperties.svelte @@ -1494,6 +1494,21 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual {#if showAdvanced} + + {#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 4122523..1b586e6 100644 --- a/src-tauri/render-core/src/activity.rs +++ b/src-tauri/render-core/src/activity.rs @@ -1581,10 +1581,26 @@ 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) { + // 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) { - 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 +1978,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 +2653,56 @@ 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]); + } + + #[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( 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)]