From aa4277dc8fdcdf506767049e8ff9e32876dcebbc Mon Sep 17 00:00:00 2001 From: Annalise Date: Tue, 15 Sep 2026 19:48:39 -0500 Subject: [PATCH] Add Y-axis gridlines and an always-visible value readout to graphs Single-channel docks now draw four evenly spaced y-axis gridlines with their values, and the dock header shows the channel's value at the end of the active range at rest, not just while the cursor is being dragged. Claude-Session: https://claude.ai/code/session_019DvUhHPz4qmusxqSDqagLd --- crates/sde-app/src/graph.rs | 59 +++++++++++++++++++++++++++++ crates/sde-app/src/main.rs | 75 +++++++++++++++++++++++++++++++++++++ crates/sde-app/ui/app.slint | 38 ++++++++++++++++++- 3 files changed, 171 insertions(+), 1 deletion(-) 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;