//! 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, 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, /// Total ride distance, if the profile is measured in distance. pub total_metres: Option, /// `[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>, pub elevation_min_m: Option, pub elevation_max_m: Option, pub total_ascent_m: Option, pub blocks: Vec, /// 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, pub elevation: Vec, /// Cumulative ascent at each sample, so "climbing remaining" is a /// subtraction rather than a scan. pub cum_ascent: Vec, pub total_x: f64, pub x_unit: XUnit, pub looping: bool, pub total_seconds: Option, pub total_metres: Option, } impl ProfileGeometry { /// Elevation at a position on the axis, linearly interpolated. pub fn elevation_at(&self, x: f64) -> Option { 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 { 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 { self.cum_ascent.last().copied() } } fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option { 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) -> (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> = 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> { 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 ), Block::Segments { segments } => { let d: f64 = segments.iter().map(|s| s.distance_m).sum(); format!("{} segments · {:.1} km", segments.len(), d / 1000.0) } Block::Terrain { points } => { let d = points.last().map(|p| p.distance_m).unwrap_or(0.0); format!("terrain · {:.1} km", d / 1000.0) } } }