Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/src/components/panels/ElementProperties.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,21 @@ Looks unrealistic for ${item.value} (expected ${issue.expected}). Enter a manual
</section>

{#if showAdvanced}
<!-- X-axis basis: time (sample index) vs distance travelled. A course
plots geography and `distance` is already the axis, so neither
offers the choice. -->
{#if item.value !== 'course' && item.value !== 'distance'}
<section class="mb-4 space-y-2">
<p class="text-[10px] uppercase tracking-wider text-zinc-600">X-Axis</p>
<label class="flex items-center justify-between gap-3 rounded-[6px] border border-transparent bg-[var(--panel2)] px-2.5 py-2">
<span class="text-xs text-zinc-500">Distance-based</span>
<Switch checked={item.x_axis === 'distance'} ariaLabel="Distance-based x-axis" onchange={(v) => update('x_axis', v ? 'distance' : undefined)} />
</label>
<p class="text-[10px] text-zinc-600 italic">
Off: evenly spaced by time. On: horizontal position reflects distance travelled.
</p>
</section>
{/if}
<!-- Fill -->
<section class="mb-4 space-y-2">
<p class="text-[10px] uppercase tracking-wider text-zinc-600">Fill</p>
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/elementTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 70 additions & 4 deletions src-tauri/render-core/src/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>, Vec<f64>) {
/// `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<f64>, Vec<f64>) {
// 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<f64>, Vec<f64>) {
let x: Vec<f64> = (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<f64> = (0..data.len()).map(|i| i as f64).collect();
(x, data.to_vec())
}
};
match attribute {
ATTR_DISTANCE => scalar(&self.distance),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down
70 changes: 68 additions & 2 deletions src-tauri/render-core/src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<f64> = (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<f64> = (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<f64> = (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(
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/render-core/src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChartCache> {
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.
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/render-core/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,10 @@ pub struct PlotConfig {
pub markers: Option<Vec<CourseMarkerConfig>>,
pub point_label: Option<PointLabelConfig>,
pub rotation: Option<f32>,
/// 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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down