diff --git a/Cargo.lock b/Cargo.lock index ce9b028..d904de0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1564,6 +1564,15 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_ltreeview" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6afc56194563064faa79d5acc18f2a4d32bd6dfacc949e1d5b5c8058a46e82cf" +dependencies = [ + "egui", +] + [[package]] name = "egui_plot" version = "0.35.0" @@ -5439,6 +5448,7 @@ dependencies = [ "can-dbc", "eframe", "egui", + "egui_ltreeview", "egui_plot", "libsvld", ] diff --git a/svldplotter/Cargo.toml b/svldplotter/Cargo.toml index 3e723a5..74e71c7 100644 --- a/svldplotter/Cargo.toml +++ b/svldplotter/Cargo.toml @@ -15,3 +15,4 @@ can-dbc = "9.1" eframe = "0.34" egui = "0.34" egui_plot = "0.35" +egui_ltreeview = "0.7" diff --git a/svldplotter/src/main.rs b/svldplotter/src/main.rs index dea6410..3d3d468 100644 --- a/svldplotter/src/main.rs +++ b/svldplotter/src/main.rs @@ -2,8 +2,9 @@ use std::{fs, path::Path}; use can_dbc::Dbc; use eframe::egui; -use egui::Vec2b; -use egui_plot::{Line, Plot, PlotPoint, PlotPoints}; +use egui::{ScrollArea, Vec2b}; +use egui_ltreeview::{Action, TreeView}; +use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoint, PlotPoints, VPlacement}; use libsvld::DecodeResult; fn main() { @@ -11,10 +12,30 @@ fn main() { eframe::run_native("My egui App", native_options, Box::new(|cc| Ok(Box::new(MyEguiApp::new(cc))))).unwrap(); } +struct SigNode { + id: u64, + name: String, +} + +struct MsgNode { + id: u64, + label: String, + signals: Vec, +} + +struct PlottedSignal { + id: u64, + label: String, + unit: String, + points: Vec, +} + struct MyEguiApp { data: DecodeResult, - points: Vec, - buffer: Vec, + tree: Vec, + filter: String, + plotted: Vec, + cursor_x: Option, } impl MyEguiApp { @@ -29,31 +50,88 @@ impl MyEguiApp { let data = libsvld::decode(&dbc, asc_path); - let mut points = Vec::::new(); - let apps_sig = data - .metadata - .get(&48) - .unwrap() - .signals - .iter() - .find(|x| x.name == "AS_Torque_Vector") - .unwrap(); - for frame in data.data.get(&48).unwrap() { - let bytes = u64::from_le_bytes(frame.data); - points.push(PlotPoint { - x: frame.timestamp, - y: libsvld::motec::extract_signal(bytes, &apps_sig.signal) - }); - } + let tree = build_tree(&dbc, &data); Self { data, - points, - buffer: Vec::new(), + tree, + filter: String::new(), + plotted: Vec::new(), + cursor_x: None, } } } +fn compute_signal( + data: &DecodeResult, + msg_id: u32, + sig_idx: usize, + id: u64, +) -> Option { + let meta = data.metadata.get(&msg_id)?; + let sig = meta.signals.get(sig_idx)?; + let frames = data.data.get(&msg_id)?; + let mut points = Vec::with_capacity(frames.len()); + for frame in frames { + let bytes = u64::from_le_bytes(frame.data); + points.push(PlotPoint { + x: frame.timestamp, + y: libsvld::motec::extract_signal(bytes, &sig.signal), + }); + } + Some(PlottedSignal { + id, + label: sig.name.clone(), + unit: sig.unit.clone(), + points, + }) +} + +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); + msgs.into_iter() + .map(|m| { + let name = dbc + .messages + .iter() + .find(|x| x.id.raw() == m.id) + .map(|x| x.name.as_str()) + .unwrap_or("?"); + let label = format!("0x{:X} {}", m.id, name); + let signals = m + .signals + .iter() + .enumerate() + .map(|(i, s)| SigNode { + id: (1u64 << 63) | ((m.id as u64) << 16) | (i as u64), + name: s.name.clone(), + }) + .collect(); + MsgNode { + id: m.id as u64, + label, + signals, + } + }) + .collect() +} + +fn nearest_y(points: &[PlotPoint], x: f64) -> Option { + if points.is_empty() { + return None; + } + let i = points.partition_point(|p| p.x < x); + let cand = [ + i.checked_sub(1).map(|j| &points[j]), + points.get(i), + ]; + cand.into_iter() + .flatten() + .min_by(|a, b| (a.x - x).abs().total_cmp(&(b.x - x).abs())) + .map(|p| p.y) +} + // Min/max bucketing: for each horizontal pixel column in [x_min, x_max], emit // the min-y and max-y point so spikes survive decimation. Pushed in x order // (min/max swapped if needed) to keep the line monotonically increasing in x. @@ -108,22 +186,165 @@ fn decimate( impl eframe::App for MyEguiApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { - let full_min = self.points.first().map(|p| p.x).unwrap_or(0.0); - let full_max = self.points.last().map(|p| p.x).unwrap_or(1.0); - let plot = Plot::new("APPS") - .include_x(full_min) - .include_x(full_max) - .allow_zoom(Vec2b{x: true, y: false}); - plot.show(ui, |pui| { - let bounds = pui.plot_bounds(); - let x_min = bounds.min()[0]; - let x_max = bounds.max()[0]; - let px_w = pui.transform().frame().width() as usize; - decimate(&self.points, x_min, x_max, px_w.max(1), &mut self.buffer); - let ppoints = PlotPoints::Borrowed(&self.buffer); - pui.line(Line::new("AS_Torque_Vector", ppoints)); + egui::Panel::left(egui::Id::new("Signals")) + .resizable(true) + .show_inside(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.filter) + .hint_text("Filter messages/signals") + .desired_width(f32::INFINITY), + ); + let needle = self.filter.trim().to_lowercase(); + let mut toggles: Vec = Vec::new(); + 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| { + for msg in &self.tree { + let msg_hit = needle.is_empty() + || msg.label.to_lowercase().contains(&needle); + let matching_sigs: Vec<&SigNode> = if msg_hit { + msg.signals.iter().collect() + } else { + msg.signals + .iter() + .filter(|s| s.name.to_lowercase().contains(&needle)) + .collect() + }; + if !msg_hit && matching_sigs.is_empty() { + continue; + } + builder.dir(msg.id, msg.label.as_str()); + for sig in matching_sigs { + builder.leaf(sig.id, sig.name.as_str()); + } + builder.close_dir(); + } + }); + for action in actions { + if let Action::SetSelected(ids) = action { + for id in ids { + if id & (1u64 << 63) != 0 { + toggles.push(id); + } + } + } + } + }); + for id in toggles { + if let Some(pos) = self.plotted.iter().position(|p| p.id == id) { + self.plotted.remove(pos); + } else { + let msg_id = ((id >> 16) & 0xFFFF_FFFF) as u32; + let sig_idx = (id & 0xFFFF) as usize; + if let Some(sig) = compute_signal(&self.data, msg_id, sig_idx, id) { + self.plotted.push(sig); + } + } + } }); + egui::CentralPanel::default().show_inside(ui, |ui| { + if self.plotted.is_empty() { + ui.centered_and_justified(|ui| { + ui.label("Click a signal in the tree to plot it."); + }); + return; + } + + // Group signals by unit, preserving the order each unit first appeared. + let mut groups: Vec<(String, Vec)> = Vec::new(); + for (i, sig) in self.plotted.iter().enumerate() { + if let Some(g) = groups.iter_mut().find(|(u, _)| u == &sig.unit) { + g.1.push(i); + } else { + groups.push((sig.unit.clone(), vec![i])); + } + } + + let full_min = self + .plotted + .iter() + .filter_map(|s| s.points.first().map(|p| p.x)) + .fold(f64::INFINITY, f64::min); + let full_max = self + .plotted + .iter() + .filter_map(|s| s.points.last().map(|p| p.x)) + .fold(f64::NEG_INFINITY, f64::max); + + let link_group = ui.id().with("plot-link"); + let n = groups.len(); + // ui.spacing_mut().item_spacing.y = 0.0; + let h = (ui.available_height() / n as f32).max(80.0); + + let cursor_x = self.cursor_x; + let mut new_cursor_x: Option = None; + + for (gi, (unit, idxs)) in groups.iter().enumerate() { + let is_first = gi == 0; + let is_last = gi + 1 == n; + + let mut x_axes: Vec = Vec::new(); + if is_first { + x_axes.push(AxisHints::new_x().placement(VPlacement::Top)); + } + if is_last { + x_axes.push(AxisHints::new_x().placement(VPlacement::Bottom)); + } + + let title = if unit.is_empty() { "(no unit)" } else { unit.as_str() }; + + let plot = Plot::new(("plot", title)) + .height(h) + .include_x(full_min) + .include_x(full_max) + .allow_zoom(Vec2b { x: true, y: true }) + .link_axis(link_group, Vec2b { x: true, y: false }) + .link_cursor(link_group, Vec2b { x: true, y: false }) + .legend(Legend::default()) + .custom_x_axes(x_axes) + .custom_y_axes(vec![AxisHints::new_y().min_thickness(48.0)]); + + let plotted = &self.plotted; + let plot_resp = plot.show(ui, |pui| { + let bounds = pui.plot_bounds(); + let x_min = bounds.min()[0]; + let x_max = bounds.max()[0]; + let px_w = pui.transform().frame().width() as usize; + let mut buf = Vec::new(); + for &i in idxs { + let sig = &plotted[i]; + decimate(&sig.points, x_min, x_max, px_w.max(1), &mut buf); + let ppoints = PlotPoints::Owned(std::mem::take(&mut buf)); + let unit_s = if sig.unit.is_empty() { + String::new() + } else { + format!(" [{}]", sig.unit) + }; + let name = match cursor_x.and_then(|x| nearest_y(&sig.points, x)) { + Some(v) => format!("{}: {:.4}{}", sig.label, v, unit_s), + None => format!("{}{}", sig.label, unit_s), + }; + pui.line(Line::new(name, ppoints)); + } + + pui.pointer_coordinate().map(|c| c.x) + }); + if let Some(x) = plot_resp.inner { + new_cursor_x = Some(x); + } + } + + self.cursor_x = new_cursor_x; }); } }