diff --git a/svldplotter/src/plot.rs b/svldplotter/src/plot.rs index aaf2b72..d00f5f5 100644 --- a/svldplotter/src/plot.rs +++ b/svldplotter/src/plot.rs @@ -3,8 +3,31 @@ use egui::ecolor::Hsva; use egui::{Color32, Vec2b}; use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoints, VPlacement}; +use egui_plot::PlotPoint; + use crate::signal::{decimate, nearest_y, PlottedSignal}; +/// Split points into runs separated by x-gaps larger than `threshold`, +/// so the line is broken across holes in the data instead of bridged. +fn split_at_gaps(points: Vec, threshold: f64) -> Vec> { + let mut segments = Vec::new(); + let mut current = Vec::new(); + let mut prev_x: Option = None; + for p in points { + if let Some(px) = prev_x { + if p.x - px > threshold && !current.is_empty() { + segments.push(std::mem::take(&mut current)); + } + } + prev_x = Some(p.x); + current.push(p); + } + if !current.is_empty() { + segments.push(current); + } + segments +} + const MIN_PLOT_HEIGHT: f32 = 80.0; const Y_AXIS_MIN_THICKNESS: f32 = 48.0; @@ -41,9 +64,11 @@ fn full_x_range(signals: &[PlottedSignal]) -> (f64, f64) { (min, max) } -fn color_for(id: u64) -> Color32 { - let mixed = (id.wrapping_mul(2654435761)) as u32; - let hue = mixed as f32 / u32::MAX as f32; +/// Per-plot signal color. Index-based with golden-ratio hue spacing so +/// neighbours on the same plot stay visually distinct. +fn color_for_index(index: usize) -> Color32 { + const GOLDEN_RATIO_CONJUGATE: f32 = 0.618_034; + let hue = (index as f32 * GOLDEN_RATIO_CONJUGATE).fract(); Hsva::new(hue, 0.85, 0.9, 1.0).into() } @@ -119,12 +144,24 @@ pub fn show_plot_panel( let x_min = bounds.min()[0]; let x_max = bounds.max()[0]; let pixel_width = plot_ui.transform().frame().width() as usize; + let bucket_w = (x_max - x_min) / pixel_width.max(1) as f64; let mut buf = Vec::new(); - for sig in &group.signals { + for (sig_index, sig) in group.signals.iter().enumerate() { decimate(&sig.points, x_min, x_max, pixel_width.max(1), &mut buf); - let plot_points = PlotPoints::Owned(std::mem::take(&mut buf)); + // When zoomed out, adjacent decimated points are bucket_w + // apart, so don't split on anything smaller than that. + let threshold = sig.gap_threshold.max(bucket_w * 1.5); + let segments = split_at_gaps(std::mem::take(&mut buf), threshold); + let color = color_for_index(sig_index); let name = line_label(sig, prev_cursor_x); - plot_ui.line(Line::new(name, plot_points).color(color_for(sig.id))); + for (i, seg) in segments.into_iter().enumerate() { + // Only the first segment carries the name so the + // legend shows a single entry per signal. + let seg_name = if i == 0 { name.clone() } else { String::new() }; + plot_ui.line( + Line::new(seg_name, PlotPoints::Owned(seg)).color(color), + ); + } } plot_ui.pointer_coordinate().map(|c| c.x) }); diff --git a/svldplotter/src/signal.rs b/svldplotter/src/signal.rs index 46f3393..16a4b56 100644 --- a/svldplotter/src/signal.rs +++ b/svldplotter/src/signal.rs @@ -6,6 +6,20 @@ pub struct PlottedSignal { pub label: String, pub unit: String, pub points: Vec, + pub gap_threshold: f64, +} + +/// A jump in x larger than this is treated as a hole in the data and the +/// line is broken rather than bridged. Based on the median sample interval. +fn compute_gap_threshold(points: &[PlotPoint]) -> f64 { + if points.len() < 2 { + return f64::INFINITY; + } + let mut deltas: Vec = + points.windows(2).map(|w| w[1].x - w[0].x).collect(); + deltas.sort_by(|a, b| a.total_cmp(b)); + let median = deltas[deltas.len() / 2]; + (median * 10.0).max(f64::MIN_POSITIVE) } pub fn compute_signal( @@ -25,11 +39,13 @@ pub fn compute_signal( y: libsvld::motec::extract_signal(bytes, &sig.signal), }); } + let gap_threshold = compute_gap_threshold(&points); Some(PlottedSignal { id, label: sig.name.clone(), unit: sig.unit.clone(), points, + gap_threshold, }) }