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>
350 lines
11 KiB
Rust
350 lines
11 KiB
Rust
//! 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(),
|
||
}
|
||
}
|