diff --git a/crates/sde-app/src/graph.rs b/crates/sde-app/src/graph.rs index d8eac84..6219426 100644 --- a/crates/sde-app/src/graph.rs +++ b/crates/sde-app/src/graph.rs @@ -344,6 +344,37 @@ pub struct MultiPlotData { pub series: Vec, pub view_width: f64, pub view_height: f64, + // The padded `(min, max)` value range every series was scaled against + // (i.e. `min_val`/`min_val + val_span` from [`value_scale`]) — the + // same range the y-flip in this function's plotting math uses, so a + // gridline built from these two numbers via [`gridline_values`] lines + // up with where the traces actually render, not the raw unpadded data + // range. + pub min_val: f64, + pub max_val: f64, +} + +/// Evenly spaced gridline positions across `[min, max]`, as +/// `(y_fraction, value)` pairs: `y_fraction` runs `0.0` (at `max`) to +/// `1.0` (at `max`'s opposite end, `min`), top-to-bottom, matching the +/// y-flip every plot in this module already applies (`view_height - (v - +/// min_val) / val_span * view_height` — a larger value plots at a smaller +/// y). `count` gridlines are returned, evenly spaced inclusive of both +/// ends; `count == 0` returns an empty `Vec`, and `count == 1` returns a +/// single gridline at `max`. Degenerate `min == max` still returns +/// `count` gridlines, all at that same value. +#[must_use] +pub fn gridline_values(min: f64, max: f64, count: usize) -> Vec<(f64, f64)> { + if count == 0 { + return Vec::new(); + } + let denom = count.saturating_sub(1).max(1) as f64; + (0..count) + .map(|i| { + let t = i as f64 / denom; + (t, max - t * (max - min)) + }) + .collect() } /// The shared time-axis span for a set of `(start_ms, end_ms)` lap @@ -677,6 +708,8 @@ pub fn build_lap_comparison_plot( series, view_width, view_height, + min_val, + max_val: min_val + val_span, }) } @@ -1415,6 +1448,32 @@ mod tests { assert_eq!(shared_duration(&[]), f64::EPSILON); } + #[test] + fn gridline_values_normal_case_is_evenly_spaced_top_to_bottom() { + assert_eq!( + gridline_values(0.0, 30.0, 4), + vec![ + (0.0, 30.0), + (1.0 / 3.0, 20.0), + (2.0 / 3.0, 10.0), + (1.0, 0.0) + ] + ); + } + + #[test] + fn gridline_values_empty_for_zero_count() { + assert_eq!(gridline_values(0.0, 30.0, 0), Vec::new()); + } + + #[test] + fn gridline_values_degenerate_range_repeats_the_same_value() { + assert_eq!( + gridline_values(5.0, 5.0, 3), + vec![(0.0, 5.0), (0.5, 5.0), (1.0, 5.0)] + ); + } + #[test] fn is_full_zoom_detects_the_unzoomed_window() { assert!(is_full_zoom((0.0, 1.0))); diff --git a/crates/sde-app/src/main.rs b/crates/sde-app/src/main.rs index c2f79e7..d828fc3 100644 --- a/crates/sde-app/src/main.rs +++ b/crates/sde-app/src/main.rs @@ -192,6 +192,9 @@ const VIEW_HEIGHT: f64 = 1000.0; #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] const GRID_COLUMNS: i32 = graph::DOCK_GRID_COLUMNS as i32; +/// Number of y-axis gridlines drawn on a single-channel dock. +const GRIDLINE_COUNT: usize = 4; + /// Where the install-root config file lives: `%APPDATA%\sde-app\` on /// Windows (this app's only target platform today — see `Cargo.toml`). /// `None` if `%APPDATA%` isn't set, in which case the install root simply @@ -998,6 +1001,52 @@ fn main() -> Result<(), slint::PlatformError> { /// while a multi-channel overlay dock prefixes each with its channel name /// (`"BRAKE: 80.0 THROTTLE: 0.0"`) so the values stay identifiable once /// more than one channel shares the graph. +/// The dock header's "value at rest" readout (`DockData.latest-value-text`): +/// each channel in `group`'s value at the *end* of each of `ranges` +/// (pipe-separated when comparing laps), unit-suffixed, in the same +/// bare-vs-`"name: value"` shape as [`cursor_text_for_group`]. Computed +/// straight from `session.channels` rather than `state.plotted`, since +/// this runs unconditionally on every `replot` — including the first +/// `replot` after a dock's channels change, before `state.plotted` has +/// necessarily been rebuilt for them. +fn latest_value_text_for_group( + session: &sde_core::Session, + group: &[String], + ranges: &[(f64, f64)], +) -> String { + let parts: Vec = group + .iter() + .map(|name| { + let units = session.channels.get(name).map(|c| c.units.as_str()); + let values = session.channels.get(name).map_or_else(String::new, |c| { + ranges + .iter() + .filter_map(|&(_, end)| { + graph::value_at_raw(&c.timecodes, &c.values, c.interpolate, end).map(|v| { + match units { + Some(u) if !u.is_empty() => format!("{v:.3} {u}"), + _ => format!("{v:.3}"), + } + }) + }) + .collect::>() + .join(" | ") + }); + let values = if values.is_empty() { + "n/a".to_string() + } else { + values + }; + if group.len() == 1 { + values + } else { + format!("{name}: {values}") + } + }) + .collect(); + parts.join(" ") +} + fn cursor_text_for_group(state: &AppState, group: &[String], abs_times: &[f64]) -> String { let parts: Vec = group .iter() @@ -1682,6 +1731,10 @@ fn replot(window: &AppWindow, state: &Rc>) { // channel. Backs the per-channel legend's remove control. let mut channel_legend: Vec = Vec::new(); let mut any_data = false; + // Only ever set for a single-channel dock (see below) — an + // overlay dock has no single shared y-scale to draw gridlines + // against. + let mut single_channel_range: Option<(f64, f64)> = None; // One color per (channel, lap-range) combination generated, // sequential in that order — for the common single-channel case // this is exactly the old per-lap coloring; overlaying channels @@ -1716,6 +1769,9 @@ fn replot(window: &AppWindow, state: &Rc>) { distance_channel, ) { any_data = true; + if group.len() == 1 { + single_channel_range = Some((plot.min_val, plot.max_val)); + } // Only label traces when the dock overlays more than one // channel — with a single channel the dock header already // names it, and lap comparison already has its own @@ -1760,7 +1816,22 @@ fn replot(window: &AppWindow, state: &Rc>) { _ => String::new(), }; + let latest_value_text = latest_value_text_for_group(session, group, &ranges); + if any_data { + #[allow(clippy::cast_possible_truncation)] + let gridlines: Vec = single_channel_range + .map(|(min_val, max_val)| { + graph::gridline_values(min_val, max_val, GRIDLINE_COUNT) + .into_iter() + .map(|(y, v)| GridlineData { + y: y as f32, + label: format!("{v:.3}").into(), + }) + .collect() + }) + .unwrap_or_default(); + docks.push(DockData { channel_name: channel_name.into(), channel_units: channel_units.into(), @@ -1777,6 +1848,8 @@ fn replot(window: &AppWindow, state: &Rc>) { status_text: String::new().into(), grid_row, grid_col, + gridlines: slint::ModelRc::new(slint::VecModel::from(gridlines)), + latest_value_text: latest_value_text.into(), }); } else { #[allow(clippy::cast_possible_truncation)] @@ -1791,6 +1864,8 @@ fn replot(window: &AppWindow, state: &Rc>) { status_text: "No samples in this range.".into(), grid_row, grid_col, + gridlines: slint::ModelRc::new(slint::VecModel::from(Vec::::new())), + latest_value_text: String::new().into(), }); } } diff --git a/crates/sde-app/ui/app.slint b/crates/sde-app/ui/app.slint index fc44484..92388e8 100644 --- a/crates/sde-app/ui/app.slint +++ b/crates/sde-app/ui/app.slint @@ -25,6 +25,14 @@ export struct LegendEntry { color: color, } +// One horizontal gridline on a single-channel dock's y-axis: `y` is a +// 0.0 (top) .. 1.0 (bottom) fraction of the dock's plotted height (see +// `graph::gridline_values`), `label` its already-formatted value text. +export struct GridlineData { + y: float, + label: string, +} + export struct DockData { channel-name: string, channel-units: string, @@ -47,6 +55,15 @@ export struct DockData { // mode below never needs modulo/division inside markup. grid-row: int, grid-col: int, + // Y-axis scale labels; only populated for single-channel docks (an + // overlay dock has no single shared scale — see `replot` in + // main.rs). Empty for a no-data dock too. + gridlines: [GridlineData], + // The dock header's "value at rest" readout: the plotted channel(s)' + // value at the end of the active range(s), already formatted. + // Overridden live by the drag-cursor readout (`cursor-value`) while + // dragging. + latest-value-text: string, } // One line in the setup panel: either a group header or a value/diff row. @@ -175,7 +192,7 @@ component DockPanel { // dock's path commands. Fixed width (rather than sizing // to content) so the row doesn't reflow every time the // value's digit count changes as the cursor moves. - text: root.cursor-visible ? root.cursor-value : ""; + text: root.cursor-visible ? root.cursor-value : root.dock.latest-value-text; font-size: 13px; color: gray; min-width: 160px; @@ -322,6 +339,25 @@ component DockPanel { } } + for g in root.dock.gridlines: Rectangle { + x: 0px; + y: g.y * parent.height; + width: 100%; + height: 1px; + background: #3a3f4b; + + Text { + x: 4px; + // Labels sit just below their line, except the + // bottom-most one (which would otherwise run off the + // dock's lower edge), which sits just above instead. + y: g.y < 0.9 ? 2px : -self.height - 2px; + text: g.label; + font-size: 9px; + color: gray; + } + } + for s in root.dock.series: Path { x: 0px; y: 0px;