Plot list

This commit is contained in:
2026-05-24 16:02:42 +02:00
parent cbaaa691a6
commit d6a1bafe47
4 changed files with 365 additions and 88 deletions
+42 -11
View File
@@ -1,4 +1,5 @@
mod plot; mod plot;
mod plot_list;
mod signal; mod signal;
mod tree; mod tree;
@@ -9,8 +10,12 @@ use eframe::egui;
use libsvld::DecodeResult; use libsvld::DecodeResult;
use crate::plot::show_plot_panel; use crate::plot::show_plot_panel;
use crate::signal::{compute_signal, PlottedSignal}; use crate::plot_list::{
use crate::tree::{build_tree, decode_signal_node_id, show_side_panel, MsgNode}; 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() { fn main() {
let native_options = eframe::NativeOptions::default(); let native_options = eframe::NativeOptions::default();
@@ -26,7 +31,8 @@ struct MyEguiApp {
data: DecodeResult, data: DecodeResult,
tree: Vec<MsgNode>, tree: Vec<MsgNode>,
filter: String, filter: String,
plotted: Vec<PlottedSignal>, plots: Vec<PlotGroup>,
next_plot_id: u64,
cursor_x: Option<f64>, cursor_x: Option<f64>,
} }
@@ -46,28 +52,53 @@ impl MyEguiApp {
data, data,
tree, tree,
filter: String::new(), filter: String::new(),
plotted: Vec::new(), plots: Vec::new(),
next_plot_id: 1,
cursor_x: None, cursor_x: None,
} }
} }
fn apply_toggles(&mut self, toggles: Vec<u64>) { fn handle_dbc_toggles(&mut self, toggles: Vec<u64>) {
for id in toggles { for id in toggles {
if let Some(pos) = self.plotted.iter().position(|p| p.id == id) { if contains_signal(&self.plots, id) {
self.plotted.remove(pos); remove_signal(&mut self.plots, id);
} else if let Some((msg_id, sig_idx)) = decode_signal_node_id(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) { 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<PlotListAction>) {
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 { 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) {
let toggles = show_side_panel(ui, &mut self.filter, &self.tree); egui::Panel::left(egui::Id::new("LeftPanel"))
self.apply_toggles(toggles); .resizable(true)
show_plot_panel(ui, &self.plotted, &mut self.cursor_x); .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);
} }
} }
+28 -46
View File
@@ -1,12 +1,14 @@
use eframe::egui; use eframe::egui;
use egui::ecolor::Hsva; use egui::ecolor::Hsva;
use egui::{Color32, Vec2b}; use egui::{Color32, Vec2b};
use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoints, VPlacement}; use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoint, PlotPoints, VPlacement};
use egui_plot::PlotPoint;
use crate::plot_list::{majority_unit, PlotGroup};
use crate::signal::{decimate, nearest_y, PlottedSignal}; 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`, /// Split points into runs separated by x-gaps larger than `threshold`,
/// so the line is broken across holes in the data instead of bridged. /// so the line is broken across holes in the data instead of bridged.
fn split_at_gaps(points: Vec<PlotPoint>, threshold: f64) -> Vec<Vec<PlotPoint>> { fn split_at_gaps(points: Vec<PlotPoint>, threshold: f64) -> Vec<Vec<PlotPoint>> {
@@ -28,37 +30,13 @@ fn split_at_gaps(points: Vec<PlotPoint>, threshold: f64) -> Vec<Vec<PlotPoint>>
segments segments
} }
const MIN_PLOT_HEIGHT: f32 = 80.0; fn full_x_range(plots: &[PlotGroup]) -> (f64, f64) {
const Y_AXIS_MIN_THICKNESS: f32 = 48.0; let signals = plots.iter().flat_map(|p| p.signals.iter());
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 let min = signals
.iter() .clone()
.filter_map(|s| s.points.first().map(|p| p.x)) .filter_map(|s| s.points.first().map(|p| p.x))
.fold(f64::INFINITY, f64::min); .fold(f64::INFINITY, f64::min);
let max = signals let max = signals
.iter()
.filter_map(|s| s.points.last().map(|p| p.x)) .filter_map(|s| s.points.last().map(|p| p.x))
.fold(f64::NEG_INFINITY, f64::max); .fold(f64::NEG_INFINITY, f64::max);
(min, max) (min, max)
@@ -86,31 +64,29 @@ fn line_label(sig: &PlottedSignal, cursor_x: Option<f64>) -> String {
pub fn show_plot_panel( pub fn show_plot_panel(
ui: &mut egui::Ui, ui: &mut egui::Ui,
plotted: &[PlottedSignal], plots: &[PlotGroup],
cursor_x: &mut Option<f64>, cursor_x: &mut Option<f64>,
) { ) {
egui::CentralPanel::default().show_inside(ui, |ui| { egui::CentralPanel::default().show_inside(ui, |ui| {
if plotted.is_empty() { if plots.is_empty() {
ui.centered_and_justified(|ui| { ui.centered_and_justified(|ui| {
ui.label("Click a signal in the tree to plot it."); ui.label("Click a signal in the tree to plot it.");
}); });
return; return;
} }
let groups = group_by_unit(plotted); let (full_min, full_max) = full_x_range(plots);
let (full_min, full_max) = full_x_range(plotted);
let link_group = ui.id().with("plot-link"); let link_group = ui.id().with("plot-link");
let plot_count = groups.len(); let plot_count = plots.len();
let plot_height = let plot_height =
(ui.available_height() / plot_count as f32).max(MIN_PLOT_HEIGHT); (ui.available_height() / plot_count as f32).max(MIN_PLOT_HEIGHT);
let prev_cursor_x = *cursor_x; let prev_cursor_x = *cursor_x;
let mut new_cursor_x: Option<f64> = None; let mut new_cursor_x: Option<f64> = None;
for (group_index, group) in groups.iter().enumerate() { for (plot_index, plot_group) in plots.iter().enumerate() {
let is_first = group_index == 0; let is_first = plot_index == 0;
let is_last = group_index + 1 == plot_count; let is_last = plot_index + 1 == plot_count;
let mut x_axes: Vec<AxisHints> = Vec::new(); let mut x_axes: Vec<AxisHints> = Vec::new();
if is_first { if is_first {
@@ -120,13 +96,9 @@ pub fn show_plot_panel(
x_axes.push(AxisHints::new_x().placement(VPlacement::Bottom)); x_axes.push(AxisHints::new_x().placement(VPlacement::Bottom));
} }
let title = if group.unit.is_empty() { let unit = majority_unit(&plot_group.signals).unwrap_or("empty");
"(no unit)"
} else {
group.unit
};
let plot = Plot::new(("plot", title)) let plot = Plot::new(("plot", plot_group.id))
.height(plot_height) .height(plot_height)
.include_x(full_min) .include_x(full_min)
.include_x(full_max) .include_x(full_max)
@@ -140,13 +112,23 @@ pub fn show_plot_panel(
]); ]);
let response = plot.show(ui, |plot_ui| { 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 bounds = plot_ui.plot_bounds();
let x_min = bounds.min()[0]; let x_min = bounds.min()[0];
let x_max = bounds.max()[0]; let x_max = bounds.max()[0];
let pixel_width = plot_ui.transform().frame().width() as usize; let pixel_width = plot_ui.transform().frame().width() as usize;
let bucket_w = (x_max - x_min) / pixel_width.max(1) as f64; let bucket_w = (x_max - x_min) / pixel_width.max(1) as f64;
let mut buf = Vec::new(); 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); decimate(&sig.points, x_min, x_max, pixel_width.max(1), &mut buf);
// When zoomed out, adjacent decimated points are bucket_w // When zoomed out, adjacent decimated points are bucket_w
// apart, so don't split on anything smaller than that. // apart, so don't split on anything smaller than that.
+253
View File
@@ -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<PlottedSignal>,
}
pub enum PlotListAction {
NewPlot,
Move(DragAndDrop<u64>),
RemovePlot(u64),
RemoveSignal(u64),
}
pub fn remove_plot(plots: &mut Vec<PlotGroup>, 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<PlotGroup>,
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<PlotGroup>, 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<PlotListAction> {
let events: RefCell<Vec<PlotListAction>> = 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::<u64>::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<PlotGroup>, dd: DragAndDrop<u64>) {
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<PlotGroup>,
signal_id: u64,
target: u64,
position: &DirPosition<u64>,
) {
// 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<PlotGroup>,
plot_id: u64,
target: u64,
position: &DirPosition<u64>,
) {
// 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);
}
+42 -31
View File
@@ -1,7 +1,7 @@
use can_dbc::Dbc; use can_dbc::Dbc;
use eframe::egui; use eframe::egui;
use egui::ScrollArea; use egui::ScrollArea;
use egui_ltreeview::{Action, TreeView}; use egui_ltreeview::{Action, TreeView, TreeViewBuilder};
use libsvld::DecodeResult; use libsvld::DecodeResult;
pub struct SigNode { pub struct SigNode {
@@ -15,7 +15,13 @@ pub struct MsgNode {
pub signals: Vec<SigNode>, pub signals: Vec<SigNode>,
} }
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 { pub fn signal_node_id(msg_id: u32, sig_idx: usize) -> u64 {
SIGNAL_FLAG | ((msg_id as u64) << 16) | (sig_idx as 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)) 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<u64> {
if id & SIGNAL_FLAG != 0 || id & PLOT_FLAG == 0 {
return None;
}
Some(id & !PLOT_FLAG)
}
pub fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec<MsgNode> { pub fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec<MsgNode> {
let mut msgs: Vec<&libsvld::motec::MessageMetadata> = data.metadata.values().collect(); let mut msgs: Vec<&libsvld::motec::MessageMetadata> = data.metadata.values().collect();
msgs.sort_by_key(|m| m.id); msgs.sort_by_key(|m| m.id);
@@ -60,32 +77,27 @@ pub fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec<MsgNode> {
.collect() .collect()
} }
/// Render the side panel and return any signal IDs the user clicked to toggle. /// Render the DBC tree and return the signal IDs the user clicked to toggle.
pub fn show_side_panel( pub fn show_dbc_tree(
ui: &mut egui::Ui, ui: &mut egui::Ui,
filter: &mut String, filter: &mut String,
tree: &[MsgNode], tree: &[MsgNode],
) -> Vec<u64> { ) -> Vec<u64> {
let mut toggles = Vec::new(); let mut toggles = Vec::new();
egui::Panel::left(egui::Id::new("Signals")) ui.add(
.resizable(true) egui::TextEdit::singleline(filter)
.show_inside(ui, |ui| { .hint_text("Filter messages/signals")
ui.add( .desired_width(f32::INFINITY),
egui::TextEdit::singleline(filter) );
.hint_text("Filter messages/signals") let needle = filter.trim().to_lowercase();
.desired_width(f32::INFINITY), ScrollArea::both()
); .scroll([true, true])
let needle = filter.trim().to_lowercase(); .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
ScrollArea::both() .show(ui, |ui| {
.scroll([true, true]) let (_resp, actions) =
.scroll_bar_visibility( TreeView::<u64>::new(ui.make_persistent_id("SignalsTree"))
egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded, .allow_drag_and_drop(false)
) .show(ui, |builder: &mut TreeViewBuilder<'_, u64>| {
.show(ui, |ui| {
let (_resp, actions) = TreeView::<u64>::new(
ui.make_persistent_id("SignalsTree"),
)
.show(ui, |builder| {
for msg in tree { for msg in tree {
let msg_hit = needle.is_empty() let msg_hit = needle.is_empty()
|| msg.label.to_lowercase().contains(&needle); || msg.label.to_lowercase().contains(&needle);
@@ -107,16 +119,15 @@ pub fn show_side_panel(
builder.close_dir(); builder.close_dir();
} }
}); });
for action in actions { for action in actions {
if let Action::SetSelected(ids) = action { if let Action::SetSelected(ids) = action {
for id in ids { for id in ids {
if id & SIGNAL_FLAG != 0 { if id & SIGNAL_FLAG != 0 {
toggles.push(id); toggles.push(id);
}
}
} }
} }
}); }
}
}); });
toggles toggles
} }