Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions crates/sde-app/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,37 @@ pub struct MultiPlotData {
pub series: Vec<SeriesPlot>,
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
Expand Down Expand Up @@ -677,6 +708,8 @@ pub fn build_lap_comparison_plot(
series,
view_width,
view_height,
min_val,
max_val: min_val + val_span,
})
}

Expand Down Expand Up @@ -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)));
Expand Down
75 changes: 75 additions & 0 deletions crates/sde-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String> = 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::<Vec<_>>()
.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<String> = group
.iter()
Expand Down Expand Up @@ -1682,6 +1731,10 @@ fn replot(window: &AppWindow, state: &Rc<RefCell<AppState>>) {
// channel. Backs the per-channel legend's remove control.
let mut channel_legend: Vec<LegendEntry> = 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
Expand Down Expand Up @@ -1716,6 +1769,9 @@ fn replot(window: &AppWindow, state: &Rc<RefCell<AppState>>) {
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
Expand Down Expand Up @@ -1760,7 +1816,22 @@ fn replot(window: &AppWindow, state: &Rc<RefCell<AppState>>) {
_ => 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<GridlineData> = 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(),
Expand All @@ -1777,6 +1848,8 @@ fn replot(window: &AppWindow, state: &Rc<RefCell<AppState>>) {
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)]
Expand All @@ -1791,6 +1864,8 @@ fn replot(window: &AppWindow, state: &Rc<RefCell<AppState>>) {
status_text: "No samples in this range.".into(),
grid_row,
grid_col,
gridlines: slint::ModelRc::new(slint::VecModel::from(Vec::<GridlineData>::new())),
latest_value_text: String::new().into(),
});
}
}
Expand Down
38 changes: 37 additions & 1 deletion crates/sde-app/ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down