plotter refactor

This commit is contained in:
2026-05-24 15:22:58 +02:00
parent 738fb727d2
commit 4cbf8bff6d
4 changed files with 385 additions and 307 deletions
+30 -307
View File
@@ -1,33 +1,25 @@
mod plot;
mod signal;
mod tree;
use std::{fs, path::Path}; use std::{fs, path::Path};
use can_dbc::Dbc; use can_dbc::Dbc;
use eframe::egui; use eframe::egui;
use egui::{ScrollArea, Vec2b};
use egui_ltreeview::{Action, TreeView};
use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoint, PlotPoints, VPlacement};
use libsvld::DecodeResult; 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};
fn main() { fn main() {
let native_options = eframe::NativeOptions::default(); let native_options = eframe::NativeOptions::default();
eframe::run_native("My egui App", native_options, Box::new(|cc| Ok(Box::new(MyEguiApp::new(cc))))).unwrap(); eframe::run_native(
} "My egui App",
native_options,
struct SigNode { Box::new(|cc| Ok(Box::new(MyEguiApp::new(cc)))),
id: u64, )
name: String, .unwrap();
}
struct MsgNode {
id: u64,
label: String,
signals: Vec<SigNode>,
}
struct PlottedSignal {
id: u64,
label: String,
unit: String,
points: Vec<PlotPoint>,
} }
struct MyEguiApp { struct MyEguiApp {
@@ -40,16 +32,14 @@ struct MyEguiApp {
impl MyEguiApp { impl MyEguiApp {
fn new(_cc: &eframe::CreationContext<'_>) -> Self { fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let args: Vec::<_> = std::env::args().collect(); let args: Vec<_> = std::env::args().collect();
let data = let dbc_text =
fs::read_to_string(Path::new(&args[1])).expect("Unable to read input file"); fs::read_to_string(Path::new(&args[1])).expect("Unable to read input file");
let dbc = Dbc::try_from(data.as_str()).expect("Failed to parse dbc file"); let dbc = Dbc::try_from(dbc_text.as_str()).expect("Failed to parse dbc file");
let asc_path = Path::new(&args[2]); let asc_path = Path::new(&args[2]);
let data = libsvld::decode(&dbc, asc_path); let data = libsvld::decode(&dbc, asc_path);
let tree = build_tree(&dbc, &data); let tree = build_tree(&dbc, &data);
Self { Self {
@@ -60,291 +50,24 @@ impl MyEguiApp {
cursor_x: None, cursor_x: None,
} }
} }
}
fn compute_signal( fn apply_toggles(&mut self, toggles: Vec<u64>) {
data: &DecodeResult, for id in toggles {
msg_id: u32, if let Some(pos) = self.plotted.iter().position(|p| p.id == id) {
sig_idx: usize, self.plotted.remove(pos);
id: u64, } else if let Some((msg_id, sig_idx)) = decode_signal_node_id(id) {
) -> Option<PlottedSignal> { if let Some(sig) = compute_signal(&self.data, msg_id, sig_idx, id) {
let meta = data.metadata.get(&msg_id)?; self.plotted.push(sig);
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<MsgNode> {
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<f64> {
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.
fn decimate(
points: &[PlotPoint],
x_min: f64,
x_max: f64,
buckets: usize,
out: &mut Vec<PlotPoint>,
) {
out.clear();
if points.is_empty() || buckets == 0 || !(x_max > x_min) {
return;
}
let lo = points.partition_point(|p| p.x < x_min).saturating_sub(1);
let hi = (points.partition_point(|p| p.x <= x_max) + 1).min(points.len());
let visible = &points[lo..hi];
if visible.len() <= buckets * 2 {
out.extend_from_slice(visible);
return;
}
let bucket_w = (x_max - x_min) / buckets as f64;
let mut i = 0;
for b in 0..buckets {
let b_end = x_min + (b as f64 + 1.0) * bucket_w;
let start = i;
while i < visible.len() && visible[i].x < b_end {
i += 1;
}
if start == i {
continue;
}
let slice = &visible[start..i];
let mut min_p = slice[0];
let mut max_p = slice[0];
for p in &slice[1..] {
if p.y < min_p.y { min_p = *p; }
if p.y > max_p.y { max_p = *p; }
}
if min_p.x <= max_p.x {
out.push(min_p);
out.push(max_p);
} else {
out.push(max_p);
out.push(min_p);
} }
} }
} }
impl eframe::App for MyEguiApp { impl eframe::App for MyEguiApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::Panel::left(egui::Id::new("Signals")) let toggles = show_side_panel(ui, &mut self.filter, &self.tree);
.resizable(true) self.apply_toggles(toggles);
.show_inside(ui, |ui| { show_plot_panel(ui, &self.plotted, &mut self.cursor_x);
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<u64> = Vec::new();
ScrollArea::both()
.scroll([
true,
true
])
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded
)
.show(ui, |ui| {
let (_resp, actions) = TreeView::<u64>::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<usize>)> = 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<f64> = 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<AxisHints> = 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;
});
}
} }
+131
View File
@@ -0,0 +1,131 @@
use eframe::egui;
use egui::Vec2b;
use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoints, VPlacement};
use crate::signal::{decimate, nearest_y, PlottedSignal};
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<UnitGroup<'_>> {
let mut groups: Vec<UnitGroup<'_>> = 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) {
let min = signals
.iter()
.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)
}
fn line_label(sig: &PlottedSignal, cursor_x: Option<f64>) -> String {
let unit_suffix = if sig.unit.is_empty() {
String::new()
} else {
format!(" [{}]", sig.unit)
};
match cursor_x.and_then(|x| nearest_y(&sig.points, x)) {
Some(v) => format!("{}: {:.4}{}", sig.label, v, unit_suffix),
None => format!("{}{}", sig.label, unit_suffix),
}
}
pub fn show_plot_panel(
ui: &mut egui::Ui,
plotted: &[PlottedSignal],
cursor_x: &mut Option<f64>,
) {
egui::CentralPanel::default().show_inside(ui, |ui| {
if plotted.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 link_group = ui.id().with("plot-link");
let plot_count = groups.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<f64> = None;
for (group_index, group) in groups.iter().enumerate() {
let is_first = group_index == 0;
let is_last = group_index + 1 == plot_count;
let mut x_axes: Vec<AxisHints> = 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 group.unit.is_empty() {
"(no unit)"
} else {
group.unit
};
let plot = Plot::new(("plot", title))
.height(plot_height)
.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(Y_AXIS_MIN_THICKNESS),
]);
let response = plot.show(ui, |plot_ui| {
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 mut buf = Vec::new();
for sig in &group.signals {
decimate(&sig.points, x_min, x_max, pixel_width.max(1), &mut buf);
let plot_points = PlotPoints::Owned(std::mem::take(&mut buf));
let name = line_label(sig, prev_cursor_x);
plot_ui.line(Line::new(name, plot_points));
}
plot_ui.pointer_coordinate().map(|c| c.x)
});
if let Some(x) = response.inner {
new_cursor_x = Some(x);
}
}
*cursor_x = new_cursor_x;
});
}
+102
View File
@@ -0,0 +1,102 @@
use egui_plot::PlotPoint;
use libsvld::DecodeResult;
pub struct PlottedSignal {
pub id: u64,
pub label: String,
pub unit: String,
pub points: Vec<PlotPoint>,
}
pub fn compute_signal(
data: &DecodeResult,
msg_id: u32,
sig_idx: usize,
id: u64,
) -> Option<PlottedSignal> {
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,
})
}
pub fn nearest_y(points: &[PlotPoint], x: f64) -> Option<f64> {
if points.is_empty() {
return None;
}
let i = points.partition_point(|p| p.x < x);
let candidates = [
i.checked_sub(1).map(|j| &points[j]),
points.get(i),
];
candidates
.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.
pub fn decimate(
points: &[PlotPoint],
x_min: f64,
x_max: f64,
buckets: usize,
out: &mut Vec<PlotPoint>,
) {
out.clear();
if points.is_empty() || buckets == 0 || !(x_max > x_min) {
return;
}
let start = points.partition_point(|p| p.x < x_min).saturating_sub(1);
let end = (points.partition_point(|p| p.x <= x_max) + 1).min(points.len());
let visible = &points[start..end];
if visible.len() <= buckets * 2 {
out.extend_from_slice(visible);
return;
}
let bucket_w = (x_max - x_min) / buckets as f64;
let mut cursor = 0;
for bucket in 0..buckets {
let bucket_end_x = x_min + (bucket as f64 + 1.0) * bucket_w;
let bucket_start = cursor;
while cursor < visible.len() && visible[cursor].x < bucket_end_x {
cursor += 1;
}
if bucket_start == cursor {
continue;
}
let slice = &visible[bucket_start..cursor];
let mut min_p = slice[0];
let mut max_p = slice[0];
for p in &slice[1..] {
if p.y < min_p.y { min_p = *p; }
if p.y > max_p.y { max_p = *p; }
}
if min_p.x <= max_p.x {
out.push(min_p);
out.push(max_p);
} else {
out.push(max_p);
out.push(min_p);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
use can_dbc::Dbc;
use eframe::egui;
use egui::ScrollArea;
use egui_ltreeview::{Action, TreeView};
use libsvld::DecodeResult;
pub struct SigNode {
pub id: u64,
pub name: String,
}
pub struct MsgNode {
pub id: u64,
pub label: String,
pub signals: Vec<SigNode>,
}
const SIGNAL_FLAG: u64 = 1 << 63;
pub fn signal_node_id(msg_id: u32, sig_idx: usize) -> u64 {
SIGNAL_FLAG | ((msg_id as u64) << 16) | (sig_idx as u64)
}
pub fn decode_signal_node_id(id: u64) -> Option<(u32, usize)> {
if id & SIGNAL_FLAG == 0 {
return None;
}
let msg_id = ((id >> 16) & 0xFFFF_FFFF) as u32;
let sig_idx = (id & 0xFFFF) as usize;
Some((msg_id, sig_idx))
}
pub fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec<MsgNode> {
let mut msgs: Vec<&libsvld::motec::MessageMetadata> = data.metadata.values().collect();
msgs.sort_by_key(|m| m.id);
msgs.into_iter()
.map(|msg| {
let name = dbc
.messages
.iter()
.find(|x| x.id.raw() == msg.id)
.map(|x| x.name.as_str())
.unwrap_or("?");
let label = format!("0x{:X} {}", msg.id, name);
let signals = msg
.signals
.iter()
.enumerate()
.map(|(sig_idx, signal)| SigNode {
id: signal_node_id(msg.id, sig_idx),
name: signal.name.clone(),
})
.collect();
MsgNode {
id: msg.id as u64,
label,
signals,
}
})
.collect()
}
/// Render the side panel and return any signal IDs the user clicked to toggle.
pub fn show_side_panel(
ui: &mut egui::Ui,
filter: &mut String,
tree: &[MsgNode],
) -> Vec<u64> {
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::<u64>::new(
ui.make_persistent_id("SignalsTree"),
)
.show(ui, |builder| {
for msg in tree {
let msg_hit = needle.is_empty()
|| msg.label.to_lowercase().contains(&needle);
let matching: 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.is_empty() {
continue;
}
builder.dir(msg.id, msg.label.as_str());
for sig in matching {
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 & SIGNAL_FLAG != 0 {
toggles.push(id);
}
}
}
}
});
});
toggles
}