From d6a1bafe47e7167d351743fac4670412e97fb428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stefa=C5=84ski?= Date: Sun, 24 May 2026 16:02:42 +0200 Subject: [PATCH] Plot list --- svldplotter/src/main.rs | 53 ++++++-- svldplotter/src/plot.rs | 74 ++++------ svldplotter/src/plot_list.rs | 253 +++++++++++++++++++++++++++++++++++ svldplotter/src/tree.rs | 73 +++++----- 4 files changed, 365 insertions(+), 88 deletions(-) create mode 100644 svldplotter/src/plot_list.rs diff --git a/svldplotter/src/main.rs b/svldplotter/src/main.rs index a32ce4b..4498e30 100644 --- a/svldplotter/src/main.rs +++ b/svldplotter/src/main.rs @@ -1,4 +1,5 @@ mod plot; +mod plot_list; mod signal; mod tree; @@ -9,8 +10,12 @@ use eframe::egui; use libsvld::DecodeResult; use crate::plot::show_plot_panel; -use crate::signal::{compute_signal, PlottedSignal}; -use crate::tree::{build_tree, decode_signal_node_id, show_side_panel, MsgNode}; +use crate::plot_list::{ + add_signal_auto, apply_move, contains_signal, new_plot, remove_plot, remove_signal, + show_plot_list, PlotGroup, PlotListAction, +}; +use crate::signal::compute_signal; +use crate::tree::{build_tree, decode_signal_node_id, show_dbc_tree, MsgNode}; fn main() { let native_options = eframe::NativeOptions::default(); @@ -26,7 +31,8 @@ struct MyEguiApp { data: DecodeResult, tree: Vec, filter: String, - plotted: Vec, + plots: Vec, + next_plot_id: u64, cursor_x: Option, } @@ -46,28 +52,53 @@ impl MyEguiApp { data, tree, filter: String::new(), - plotted: Vec::new(), + plots: Vec::new(), + next_plot_id: 1, cursor_x: None, } } - fn apply_toggles(&mut self, toggles: Vec) { + fn handle_dbc_toggles(&mut self, toggles: Vec) { for id in toggles { - if let Some(pos) = self.plotted.iter().position(|p| p.id == id) { - self.plotted.remove(pos); + if contains_signal(&self.plots, id) { + remove_signal(&mut self.plots, id); } else if let Some((msg_id, sig_idx)) = decode_signal_node_id(id) { if let Some(sig) = compute_signal(&self.data, msg_id, sig_idx, id) { - self.plotted.push(sig); + add_signal_auto(&mut self.plots, &mut self.next_plot_id, sig); } } } } + + fn handle_plot_actions(&mut self, actions: Vec) { + for action in actions { + match action { + PlotListAction::NewPlot => new_plot(&mut self.plots, &mut self.next_plot_id), + PlotListAction::Move(dd) => apply_move(&mut self.plots, dd), + PlotListAction::RemovePlot(id) => remove_plot(&mut self.plots, id), + PlotListAction::RemoveSignal(id) => remove_signal(&mut self.plots, id), + } + } + } } impl eframe::App for MyEguiApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - let toggles = show_side_panel(ui, &mut self.filter, &self.tree); - self.apply_toggles(toggles); - show_plot_panel(ui, &self.plotted, &mut self.cursor_x); + egui::Panel::left(egui::Id::new("LeftPanel")) + .resizable(true) + .show_inside(ui, |ui| { + let plot_actions = egui::Panel::bottom(egui::Id::new("PlotList")) + .resizable(true) + .default_size(220.0) + .show_inside(ui, |ui| show_plot_list(ui, &self.plots)) + .inner; + let toggles = egui::CentralPanel::default() + .show_inside(ui, |ui| show_dbc_tree(ui, &mut self.filter, &self.tree)) + .inner; + self.handle_dbc_toggles(toggles); + self.handle_plot_actions(plot_actions); + }); + show_plot_panel(ui, &self.plots, &mut self.cursor_x); } } + diff --git a/svldplotter/src/plot.rs b/svldplotter/src/plot.rs index d00f5f5..677215f 100644 --- a/svldplotter/src/plot.rs +++ b/svldplotter/src/plot.rs @@ -1,12 +1,14 @@ use eframe::egui; use egui::ecolor::Hsva; use egui::{Color32, Vec2b}; -use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoints, VPlacement}; - -use egui_plot::PlotPoint; +use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoint, PlotPoints, VPlacement}; +use crate::plot_list::{majority_unit, PlotGroup}; use crate::signal::{decimate, nearest_y, PlottedSignal}; +const MIN_PLOT_HEIGHT: f32 = 80.0; +const Y_AXIS_MIN_THICKNESS: f32 = 48.0; + /// 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> { @@ -28,37 +30,13 @@ fn split_at_gaps(points: Vec, threshold: f64) -> Vec> segments } -const MIN_PLOT_HEIGHT: f32 = 80.0; -const Y_AXIS_MIN_THICKNESS: f32 = 48.0; - -struct UnitGroup<'a> { - unit: &'a str, - signals: Vec<&'a PlottedSignal>, -} - -/// Group signals by unit, preserving the order each unit first appeared. -fn group_by_unit(signals: &[PlottedSignal]) -> Vec> { - let mut groups: Vec> = Vec::new(); - for sig in signals { - if let Some(g) = groups.iter_mut().find(|g| g.unit == sig.unit) { - g.signals.push(sig); - } else { - groups.push(UnitGroup { - unit: &sig.unit, - signals: vec![sig], - }); - } - } - groups -} - -fn full_x_range(signals: &[PlottedSignal]) -> (f64, f64) { +fn full_x_range(plots: &[PlotGroup]) -> (f64, f64) { + let signals = plots.iter().flat_map(|p| p.signals.iter()); let min = signals - .iter() + .clone() .filter_map(|s| s.points.first().map(|p| p.x)) .fold(f64::INFINITY, f64::min); let max = signals - .iter() .filter_map(|s| s.points.last().map(|p| p.x)) .fold(f64::NEG_INFINITY, f64::max); (min, max) @@ -86,31 +64,29 @@ fn line_label(sig: &PlottedSignal, cursor_x: Option) -> String { pub fn show_plot_panel( ui: &mut egui::Ui, - plotted: &[PlottedSignal], + plots: &[PlotGroup], cursor_x: &mut Option, ) { egui::CentralPanel::default().show_inside(ui, |ui| { - if plotted.is_empty() { + if plots.is_empty() { ui.centered_and_justified(|ui| { ui.label("Click a signal in the tree to plot it."); }); return; } - let groups = group_by_unit(plotted); - let (full_min, full_max) = full_x_range(plotted); - + let (full_min, full_max) = full_x_range(plots); let link_group = ui.id().with("plot-link"); - let plot_count = groups.len(); + let plot_count = plots.len(); let plot_height = (ui.available_height() / plot_count as f32).max(MIN_PLOT_HEIGHT); let prev_cursor_x = *cursor_x; let mut new_cursor_x: Option = None; - for (group_index, group) in groups.iter().enumerate() { - let is_first = group_index == 0; - let is_last = group_index + 1 == plot_count; + for (plot_index, plot_group) in plots.iter().enumerate() { + let is_first = plot_index == 0; + let is_last = plot_index + 1 == plot_count; let mut x_axes: Vec = Vec::new(); if is_first { @@ -120,13 +96,9 @@ pub fn show_plot_panel( x_axes.push(AxisHints::new_x().placement(VPlacement::Bottom)); } - let title = if group.unit.is_empty() { - "(no unit)" - } else { - group.unit - }; + let unit = majority_unit(&plot_group.signals).unwrap_or("empty"); - let plot = Plot::new(("plot", title)) + let plot = Plot::new(("plot", plot_group.id)) .height(plot_height) .include_x(full_min) .include_x(full_max) @@ -140,13 +112,23 @@ pub fn show_plot_panel( ]); let response = plot.show(ui, |plot_ui| { + if plot_group.signals.is_empty() { + plot_ui.line( + Line::new( + format!("Plot {} [{}]", plot_index + 1, unit), + PlotPoints::Owned(Vec::new()), + ), + ); + return plot_ui.pointer_coordinate().map(|c| c.x); + } + let bounds = plot_ui.plot_bounds(); 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_index, sig) in group.signals.iter().enumerate() { + for (sig_index, sig) in plot_group.signals.iter().enumerate() { decimate(&sig.points, x_min, x_max, pixel_width.max(1), &mut buf); // When zoomed out, adjacent decimated points are bucket_w // apart, so don't split on anything smaller than that. diff --git a/svldplotter/src/plot_list.rs b/svldplotter/src/plot_list.rs new file mode 100644 index 0000000..34d13f9 --- /dev/null +++ b/svldplotter/src/plot_list.rs @@ -0,0 +1,253 @@ +use std::cell::RefCell; + +use eframe::egui; +use egui::ScrollArea; +use egui_ltreeview::{ + Action, DirPosition, DragAndDrop, NodeBuilder, TreeView, TreeViewBuilder, +}; + +use crate::signal::PlottedSignal; +use crate::tree::{ + decode_plot_node_id, plot_node_id, PLOT_FLAG, ROOT_NODE_ID, SIGNAL_FLAG, +}; + +/// Auto-placement cap. The user can manually drag more signals into a plot. +pub const MAX_SIGNALS_PER_PLOT: usize = 5; + +pub struct PlotGroup { + pub id: u64, + pub signals: Vec, +} + +pub enum PlotListAction { + NewPlot, + Move(DragAndDrop), + RemovePlot(u64), + RemoveSignal(u64), +} + +pub fn remove_plot(plots: &mut Vec, plot_id: u64) { + plots.retain(|p| p.id != plot_id); +} + +/// Most common unit in the plot (first-wins on ties). Returns `None` +/// when the plot is empty. +pub fn majority_unit(signals: &[PlottedSignal]) -> Option<&str> { + let mut counts: Vec<(&str, usize)> = Vec::new(); + for sig in signals { + if let Some(c) = counts.iter_mut().find(|(u, _)| *u == sig.unit.as_str()) { + c.1 += 1; + } else { + counts.push((sig.unit.as_str(), 1)); + } + } + counts.into_iter().max_by_key(|(_, c)| *c).map(|(u, _)| u) +} + +pub fn contains_signal(plots: &[PlotGroup], signal_id: u64) -> bool { + plots + .iter() + .any(|p| p.signals.iter().any(|s| s.id == signal_id)) +} + +pub fn remove_signal(plots: &mut [PlotGroup], signal_id: u64) { + for plot in plots { + if let Some(pos) = plot.signals.iter().position(|s| s.id == signal_id) { + plot.signals.remove(pos); + return; + } + } +} + +/// Place `sig` into the first plot whose majority unit matches and that +/// is under the cap; otherwise append a new plot at the end. +pub fn add_signal_auto( + plots: &mut Vec, + next_plot_id: &mut u64, + sig: PlottedSignal, +) { + let unit = sig.unit.as_str(); + let slot = plots.iter_mut().find(|p| { + p.signals.len() < MAX_SIGNALS_PER_PLOT && majority_unit(&p.signals) == Some(unit) + }); + if let Some(plot) = slot { + plot.signals.push(sig); + } else { + plots.push(PlotGroup { + id: *next_plot_id, + signals: vec![sig], + }); + *next_plot_id += 1; + } +} + +pub fn new_plot(plots: &mut Vec, next_plot_id: &mut u64) { + plots.push(PlotGroup { + id: *next_plot_id, + signals: Vec::new(), + }); + *next_plot_id += 1; +} + +pub fn show_plot_list( + ui: &mut egui::Ui, + plots: &[PlotGroup], +) -> Vec { + let events: RefCell> = RefCell::new(Vec::new()); + if ui.button("+ New plot").clicked() { + events.borrow_mut().push(PlotListAction::NewPlot); + } + ScrollArea::both() + .scroll([true, true]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded) + .show(ui, |ui| { + let (_resp, actions) = + TreeView::::new(ui.make_persistent_id("PlotsTree")) + .show(ui, |builder: &mut TreeViewBuilder<'_, u64>| { + // Implicit root so the library can produce + // Before/After positions for top-level plots. + builder.dir(ROOT_NODE_ID, "Plots"); + for (i, plot) in plots.iter().enumerate() { + let unit = majority_unit(&plot.signals).unwrap_or("empty"); + let label = format!("Plot {} [{}]", i + 1, unit); + let plot_id = plot.id; + let is_empty = plot.signals.is_empty(); + let mut node = + NodeBuilder::dir(plot_node_id(plot.id)).label(label); + if is_empty { + node = node.context_menu(|ui| { + if ui.button("Remove plot").clicked() { + events + .borrow_mut() + .push(PlotListAction::RemovePlot(plot_id)); + ui.close(); + } + }); + } + builder.node(node); + for sig in &plot.signals { + let sig_id = sig.id; + let node = NodeBuilder::leaf(sig.id) + .label(sig.label.as_str()) + .context_menu(|ui| { + if ui.button("Remove signal").clicked() { + events.borrow_mut().push( + PlotListAction::RemoveSignal(sig_id), + ); + ui.close(); + } + }); + builder.node(node); + } + builder.close_dir(); + } + builder.close_dir(); + }); + for action in actions { + if let Action::Move(dd) = action { + events.borrow_mut().push(PlotListAction::Move(dd)); + } + } + }); + events.into_inner() +} + +/// Apply a drag-and-drop move to the plot list. +/// +/// Sources are either signal leaves (move signal between/within plots) or +/// plot dirs (reorder plots). Mixed sources are handled item-by-item. +pub fn apply_move(plots: &mut Vec, dd: DragAndDrop) { + for source in dd.source { + if source & SIGNAL_FLAG != 0 { + apply_signal_move(plots, source, dd.target, &dd.position); + } else if source & PLOT_FLAG != 0 { + apply_plot_move(plots, source & !PLOT_FLAG, dd.target, &dd.position); + } + } +} + +fn apply_signal_move( + plots: &mut Vec, + signal_id: u64, + target: u64, + position: &DirPosition, +) { + // Resolve the target plot. If dropped on the root or somewhere we + // can't interpret, leave the signal where it is. + let target_plot_id = if let Some(pid) = decode_plot_node_id(target) { + pid + } else { + return; + }; + + // Detach from its current plot. + let detached = plots.iter_mut().find_map(|p| { + p.signals + .iter() + .position(|s| s.id == signal_id) + .map(|pos| p.signals.remove(pos)) + }); + let Some(sig) = detached else { + return; + }; + + let Some(target_plot) = plots.iter_mut().find(|p| p.id == target_plot_id) else { + return; + }; + let insert_at = match position { + DirPosition::First => 0, + DirPosition::Last => target_plot.signals.len(), + DirPosition::Before(sibling) => target_plot + .signals + .iter() + .position(|s| s.id == *sibling) + .unwrap_or(target_plot.signals.len()), + DirPosition::After(sibling) => target_plot + .signals + .iter() + .position(|s| s.id == *sibling) + .map(|i| i + 1) + .unwrap_or(target_plot.signals.len()), + }; + target_plot.signals.insert(insert_at, sig); +} + +fn apply_plot_move( + plots: &mut Vec, + plot_id: u64, + target: u64, + position: &DirPosition, +) { + // Only reordering at the root level is supported. Dropping a plot + // onto another plot is treated as "place after that plot". + let from = match plots.iter().position(|p| p.id == plot_id) { + Some(i) => i, + None => return, + }; + let plot = plots.remove(from); + + let to = if target == ROOT_NODE_ID { + match position { + DirPosition::First => 0, + DirPosition::Last => plots.len(), + DirPosition::Before(sibling) => decode_plot_node_id(*sibling) + .and_then(|pid| plots.iter().position(|p| p.id == pid)) + .unwrap_or(plots.len()), + DirPosition::After(sibling) => decode_plot_node_id(*sibling) + .and_then(|pid| plots.iter().position(|p| p.id == pid)) + .map(|i| i + 1) + .unwrap_or(plots.len()), + } + } else if let Some(target_plot_id) = decode_plot_node_id(target) { + plots + .iter() + .position(|p| p.id == target_plot_id) + .map(|i| i + 1) + .unwrap_or(plots.len()) + } else { + plots.len() + }; + + let to = to.min(plots.len()); + plots.insert(to, plot); +} diff --git a/svldplotter/src/tree.rs b/svldplotter/src/tree.rs index 8c770d4..c7588e3 100644 --- a/svldplotter/src/tree.rs +++ b/svldplotter/src/tree.rs @@ -1,7 +1,7 @@ use can_dbc::Dbc; use eframe::egui; use egui::ScrollArea; -use egui_ltreeview::{Action, TreeView}; +use egui_ltreeview::{Action, TreeView, TreeViewBuilder}; use libsvld::DecodeResult; pub struct SigNode { @@ -15,7 +15,13 @@ pub struct MsgNode { pub signals: Vec, } -const SIGNAL_FLAG: u64 = 1 << 63; +// Node-id encoding shared across the two tree views. +// bit 63 set → signal leaf +// bit 62 set → plot directory (in the lower "plots" tree) +// ROOT_NODE_ID → the implicit root of the lower tree +pub const SIGNAL_FLAG: u64 = 1 << 63; +pub const PLOT_FLAG: u64 = 1 << 62; +pub const ROOT_NODE_ID: u64 = 1; pub fn signal_node_id(msg_id: u32, sig_idx: usize) -> u64 { SIGNAL_FLAG | ((msg_id as u64) << 16) | (sig_idx as u64) @@ -30,6 +36,17 @@ pub fn decode_signal_node_id(id: u64) -> Option<(u32, usize)> { Some((msg_id, sig_idx)) } +pub fn plot_node_id(plot_id: u64) -> u64 { + PLOT_FLAG | plot_id +} + +pub fn decode_plot_node_id(id: u64) -> Option { + if id & SIGNAL_FLAG != 0 || id & PLOT_FLAG == 0 { + return None; + } + Some(id & !PLOT_FLAG) +} + pub fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec { let mut msgs: Vec<&libsvld::motec::MessageMetadata> = data.metadata.values().collect(); msgs.sort_by_key(|m| m.id); @@ -60,32 +77,27 @@ pub fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec { .collect() } -/// Render the side panel and return any signal IDs the user clicked to toggle. -pub fn show_side_panel( +/// Render the DBC tree and return the signal IDs the user clicked to toggle. +pub fn show_dbc_tree( ui: &mut egui::Ui, filter: &mut String, tree: &[MsgNode], ) -> Vec { let mut toggles = Vec::new(); - egui::Panel::left(egui::Id::new("Signals")) - .resizable(true) - .show_inside(ui, |ui| { - ui.add( - egui::TextEdit::singleline(filter) - .hint_text("Filter messages/signals") - .desired_width(f32::INFINITY), - ); - let needle = filter.trim().to_lowercase(); - ScrollArea::both() - .scroll([true, true]) - .scroll_bar_visibility( - egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded, - ) - .show(ui, |ui| { - let (_resp, actions) = TreeView::::new( - ui.make_persistent_id("SignalsTree"), - ) - .show(ui, |builder| { + ui.add( + egui::TextEdit::singleline(filter) + .hint_text("Filter messages/signals") + .desired_width(f32::INFINITY), + ); + let needle = filter.trim().to_lowercase(); + ScrollArea::both() + .scroll([true, true]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded) + .show(ui, |ui| { + let (_resp, actions) = + TreeView::::new(ui.make_persistent_id("SignalsTree")) + .allow_drag_and_drop(false) + .show(ui, |builder: &mut TreeViewBuilder<'_, u64>| { for msg in tree { let msg_hit = needle.is_empty() || msg.label.to_lowercase().contains(&needle); @@ -107,16 +119,15 @@ pub fn show_side_panel( builder.close_dir(); } }); - for action in actions { - if let Action::SetSelected(ids) = action { - for id in ids { - if id & SIGNAL_FLAG != 0 { - toggles.push(id); - } - } + for action in actions { + if let Action::SetSelected(ids) = action { + for id in ids { + if id & SIGNAL_FLAG != 0 { + toggles.push(id); } } - }); + } + } }); toggles }