Files
BikeControl/src-tauri/src/profile_view.rs
T
dtourolleandClaude Opus 5 0c757a4a15 Colour effort by zone, and read distance in the rider's units
A watt is a fact; a zone is what it costs you. The biggest number on the
ride screen was the same shade of white at 90 W and at 400 W, which is a
thing no training app has done in fifteen years.

Zones, with two rules:

- **No reference, no zone.** An unset FTP draws the plain number. A zone
  measured against a guessed threshold would paint every ride with a
  confident lie.
- **Colour never carries it alone.** "Z4" renders beside the swatch, so
  the meaning survives a colour-blind rider, a phone in direct sun and a
  black-and-white screenshot.

Read off the rolling average, not the instantaneous watts: at 4 Hz the
raw figure crosses two boundaries every pedal stroke, and a colour that
strobes is worse than no colour. Not pedalling is not zone 1.

Units are a display preference applied at the last step before the
glass. Everything computed, stored and recorded stays SI, so a FIT file
never depends on what the screen was set to. `format.ts` takes the unit
system as an argument rather than reading a module-level setting — pure
functions are what let every readout redraw the moment it changes. The
`km` helper is gone rather than left beside `dist`, so there is no
second way to format a distance that ignores the preference.

Rust's block labels lose their baked-in kilometres. The block already
carries start_x and end_x and the frontend renders that span in the
rider's units; a kilometre in the text sat inside a sentence saying
miles everywhere else.

Also on the ride screen:

- The gradient gets a wedge beside the number. A signed decimal has to
  be read; a slope is seen. Exaggerated and clamped, because a true-scale
  6% is indistinguishable from 3% at 40 px wide.
- What is coming, from the profile's own block list — "2.1 km at 12% in
  460 m". The chart says where the rider is; what is about to happen is
  what decides whether to shift now. The data was already computed
  Rust-side and thrown away here. Close in, the small unit reads better
  than a fraction of the big one.
- Mode and target merge into one chip. They are a single fact, and
  splitting them spent a chip of header width repeating the word
  "target".
- The pod chip no longer reports a missing `+` pod while the `−` pod is
  connected. The `−` pod relays its twin, so that is the intended
  configuration — the ride screen was calling it a fault, contradicting
  the device screen two keystrokes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:11 +02:00

350 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The route, as the ride screen needs to draw it.
//!
//! `crates/core` owns profile *semantics* — `Profile::sample`,
//! `Profile::preview`, `Profile::total_extent`. This module owns the *view
//! model*: the elevation trace, the block breakdown, and the geometry needed to
//! place the current-position marker and answer "how much climbing is left".
//!
//! The route is the hero element of the ride screen, so this is the payload
//! that matters most.
use bikecontrol_core::profile::{Block, Channel, Extent, Position, Profile, Waveform};
use serde::Serialize;
/// Which axis the profile is drawn against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum XUnit {
#[default]
Seconds,
Metres,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockSummary {
pub index: usize,
/// `constant` | `ramp` | `wave` | `segments` | `terrain`
pub kind: &'static str,
pub channel: Channel,
pub label: String,
pub start_x: f64,
pub end_x: f64,
pub unit: XUnit,
}
/// Everything the UI needs to draw a profile (FR-6.7, FR-9.7).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileView {
pub name: String,
pub description: Option<String>,
pub looping: bool,
/// Where it came from: a file path, a sample name, or `"editor"`.
pub source: String,
/// The channel the value series plots.
pub channel: Channel,
pub x_unit: XUnit,
pub total_x: f64,
/// Total ride duration, if the profile is measured in time.
pub total_seconds: Option<f64>,
/// Total ride distance, if the profile is measured in distance.
pub total_metres: Option<f64>,
/// `[x, value]` along the axis — gradient %, watts or resistance level.
pub series: Vec<[f64; 2]>,
/// `[distance_m, elevation_m]`. Real elevation for GPX-derived terrain,
/// integrated from gradient otherwise. This is the hero chart.
pub elevation: Option<Vec<[f64; 2]>>,
pub elevation_min_m: Option<f32>,
pub elevation_max_m: Option<f32>,
pub total_ascent_m: Option<f32>,
pub blocks: Vec<BlockSummary>,
/// The profile as YAML, for the in-app editor.
pub yaml: String,
}
/// Precomputed geometry kept Rust-side so per-tick lookups are cheap. Never
/// serialised — the frontend gets answers, not arrays to search.
#[derive(Debug, Clone, Default)]
pub struct ProfileGeometry {
pub xs: Vec<f64>,
pub elevation: Vec<f32>,
/// Cumulative ascent at each sample, so "climbing remaining" is a
/// subtraction rather than a scan.
pub cum_ascent: Vec<f32>,
pub total_x: f64,
pub x_unit: XUnit,
pub looping: bool,
pub total_seconds: Option<f64>,
pub total_metres: Option<f64>,
}
impl ProfileGeometry {
/// Elevation at a position on the axis, linearly interpolated.
pub fn elevation_at(&self, x: f64) -> Option<f32> {
interp(&self.xs, &self.elevation, x)
}
/// Metres of climbing still to come from `x` to the end.
pub fn ascent_remaining(&self, x: f64) -> Option<f32> {
let total = *self.cum_ascent.last()?;
let done = interp(&self.xs, &self.cum_ascent, x)?;
Some((total - done).max(0.0))
}
pub fn total_ascent(&self) -> Option<f32> {
self.cum_ascent.last().copied()
}
}
fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
if xs.is_empty() || xs.len() != ys.len() {
return None;
}
if x <= xs[0] {
return Some(ys[0]);
}
let last = xs.len() - 1;
if x >= xs[last] {
return Some(ys[last]);
}
let i = xs.partition_point(|v| *v <= x).clamp(1, last);
let (x0, x1) = (xs[i - 1], xs[i]);
let (y0, y1) = (ys[i - 1], ys[i]);
let span = x1 - x0;
Some(if span.abs() < f64::EPSILON {
y1
} else {
y0 + (y1 - y0) * ((x - x0) / span) as f32
})
}
const PREVIEW_SAMPLES: usize = 1400;
fn extent_parts(extent: Extent) -> (f64, XUnit) {
match extent {
Extent::Seconds(s) => (s.max(0.0), XUnit::Seconds),
Extent::Metres(m) => (m.max(0.0), XUnit::Metres),
}
}
/// Build the view model and the geometry that goes with it.
pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, ProfileGeometry) {
let extent = profile.total_extent();
let x_unit = match (extent.metres, extent.seconds) {
(Some(m), Some(s)) => {
if m >= s {
XUnit::Metres
} else {
XUnit::Seconds
}
}
(Some(_), None) => XUnit::Metres,
_ => XUnit::Seconds,
};
let preview = profile.preview(PREVIEW_SAMPLES);
let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect();
let total_x = series.last().map(|p| p[0]).unwrap_or(0.0);
let channel = profile
.blocks
.first()
.map(|b| b.channel())
.unwrap_or(Channel::Gradient);
// Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
// block that already carries surveyed elevation. Otherwise integrate the
// gradient, which is what a hand-authored segment profile implies anyway.
let mut geom = ProfileGeometry {
total_x,
x_unit,
looping: profile.looping,
total_seconds: extent.seconds,
total_metres: extent.metres,
..Default::default()
};
let elevation: Option<Vec<[f64; 2]>> = if channel == Channel::Gradient {
let surveyed = surveyed_elevation(profile);
let pairs = match surveyed {
Some(points) => points,
None if x_unit == XUnit::Metres => integrate_gradient(&series),
None => Vec::new(),
};
if pairs.len() < 2 {
None
} else {
geom.xs = pairs.iter().map(|p| p[0]).collect();
geom.elevation = pairs.iter().map(|p| p[1] as f32).collect();
let mut cum = Vec::with_capacity(geom.elevation.len());
let mut acc = 0.0f32;
let mut prev = geom.elevation[0];
for e in &geom.elevation {
acc += (e - prev).max(0.0);
prev = *e;
cum.push(acc);
}
geom.cum_ascent = cum;
Some(pairs)
}
} else {
None
};
let (elevation_min_m, elevation_max_m) = match &geom.elevation {
e if e.is_empty() => (None, None),
e => (
Some(e.iter().copied().fold(f32::INFINITY, f32::min)),
Some(e.iter().copied().fold(f32::NEG_INFINITY, f32::max)),
),
};
let mut blocks = Vec::with_capacity(profile.blocks.len());
let mut cursor = 0.0f64;
for (index, block) in profile.blocks.iter().enumerate() {
let (span, unit) = extent_parts(block.extent());
blocks.push(BlockSummary {
index,
kind: block_kind(block),
channel: block.channel(),
label: block_label(block),
start_x: cursor,
end_x: cursor + span,
unit,
});
cursor += span;
}
let view = ProfileView {
name: profile.name.clone(),
description: profile.description.clone(),
looping: profile.looping,
source: source.into(),
channel,
x_unit,
total_x,
total_seconds: extent.seconds,
total_metres: extent.metres,
series,
elevation,
elevation_min_m,
elevation_max_m,
total_ascent_m: geom.total_ascent(),
blocks,
yaml: serde_yaml_ng::to_string(profile).unwrap_or_default(),
};
(view, geom)
}
/// Elevation straight out of `Terrain` blocks, offset so consecutive blocks
/// join up rather than each restarting at zero distance.
fn surveyed_elevation(profile: &Profile) -> Option<Vec<[f64; 2]>> {
let mut out: Vec<[f64; 2]> = Vec::new();
let mut offset = 0.0f64;
let mut any = false;
for block in &profile.blocks {
let (span, _) = extent_parts(block.extent());
if let Block::Terrain { points } = block {
any = true;
let base = points.first().map(|p| p.distance_m).unwrap_or(0.0);
for p in points {
out.push([offset + (p.distance_m - base), p.elevation_m as f64]);
}
}
offset += span;
}
any.then_some(out)
}
/// Integrate gradient over distance to get a relative elevation trace.
fn integrate_gradient(series: &[[f64; 2]]) -> Vec<[f64; 2]> {
let mut elev = 0.0f64;
let mut prev_x = series.first().map(|p| p[0]).unwrap_or(0.0);
series
.iter()
.map(|[x, grade]| {
elev += (x - prev_x).max(0.0) * (grade / 100.0);
prev_x = *x;
[*x, elev]
})
.collect()
}
/// Where the rider is on the preview axis right now.
pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f64 {
let raw = match geom.x_unit {
XUnit::Seconds => elapsed_s,
XUnit::Metres => distance_m,
};
if geom.looping && geom.total_x > 0.0 {
raw.rem_euclid(geom.total_x)
} else {
raw.clamp(0.0, geom.total_x.max(0.0))
}
}
/// Convenience wrapper so callers do not have to build a `Position`.
pub fn position(elapsed_s: f64, distance_m: f64) -> Position {
Position {
elapsed_s,
distance_m,
}
}
fn block_kind(block: &Block) -> &'static str {
match block {
Block::Constant { .. } => "constant",
Block::Ramp { .. } => "ramp",
Block::Wave { .. } => "wave",
Block::Segments { .. } => "segments",
Block::Terrain { .. } => "terrain",
}
}
fn unit_suffix(channel: Channel) -> &'static str {
match channel {
Channel::Gradient => "%",
Channel::Resistance => "",
Channel::Power => " W",
}
}
fn block_label(block: &Block) -> String {
let u = unit_suffix(block.channel());
match block {
Block::Constant { value, .. } => format!("hold {value:.0}{u}"),
Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u} → {to:.0}{u}"),
Block::Wave {
shape,
midpoint,
amplitude,
repeats,
..
} => format!(
"{} {:.0}{u} ±{:.0}{u} ×{:.0}",
match shape {
Waveform::Sine => "sine",
Waveform::Square => "square",
Waveform::Triangle => "triangle",
Waveform::Sawtooth => "sawtooth",
},
midpoint,
amplitude,
repeats
),
// No distance in the label. The block already carries `start_x` and
// `end_x`, and the frontend renders that span in the rider's own units
// (FR-7.5a) — a kilometre baked into the text here would sit inside a
// sentence that says miles everywhere else.
Block::Segments { segments } => {
format!(
"{} segment{}",
segments.len(),
if segments.len() == 1 { "" } else { "s" }
)
}
Block::Terrain { .. } => "terrain".to_string(),
}
}