Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2b8a35d2f | |||
| 6851ab988b | |||
| 5e2cfeafb9 | |||
| d8057a47d2 | |||
| e4fea66df6 | |||
| 9855f68e7f | |||
| d6a1bafe47 | |||
| cbaaa691a6 | |||
| 91d549588b | |||
| 4cbf8bff6d | |||
| 738fb727d2 | |||
| d1bfb7702d |
Generated
+935
-89
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -5,10 +5,10 @@
|
|||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["libsvld", "svldcli", "svldgui"]
|
members = ["libsvld", "svldcli", "svldgui", "svldplotter"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "fat"
|
lto = false
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
|
|||||||
+114
-65
@@ -1,6 +1,6 @@
|
|||||||
mod asc;
|
pub mod asc;
|
||||||
pub mod can_frame;
|
pub mod can_frame;
|
||||||
mod motec;
|
pub mod motec;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fs::File,
|
fs::File,
|
||||||
@@ -30,16 +30,11 @@ struct FrameOutput {
|
|||||||
error_frames: Vec<ErrorFrame>,
|
error_frames: Vec<ErrorFrame>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn frame_worker(
|
/// Build the LD metadata for a single CAN id from the DBC, applying the
|
||||||
mmap: &[u8],
|
/// project's `ld_*`/`Frequency` attribute defaults. Returns `None` when the
|
||||||
dbc: &Dbc,
|
/// id is not described by the DBC (an "unknown" id). Shared by the ASC decode
|
||||||
) -> FrameOutput {
|
/// path and the live-CAN path so both produce identical metadata.
|
||||||
let mut channel_data = HashMap::<CanId, Vec<CanFrame>>::new();
|
pub fn build_message_metadata(dbc: &Dbc, id: CanId) -> Option<MessageMetadata> {
|
||||||
let mut metadatas = HashMap::<CanId, MessageMetadata>::new();
|
|
||||||
|
|
||||||
let mut unknown_ids = HashSet::<CanId>::new();
|
|
||||||
let mut error_frames = Vec::<ErrorFrame>::new();
|
|
||||||
|
|
||||||
let get_default = |name: &str| match dbc
|
let get_default = |name: &str| match dbc
|
||||||
.attribute_defaults
|
.attribute_defaults
|
||||||
.iter()
|
.iter()
|
||||||
@@ -56,57 +51,66 @@ fn frame_worker(
|
|||||||
let default_scale = get_default("ld_scale");
|
let default_scale = get_default("ld_scale");
|
||||||
let default_decimal_places = get_default("ld_decimal_places");
|
let default_decimal_places = get_default("ld_decimal_places");
|
||||||
|
|
||||||
let mut add_metadata = |id| {
|
let msg = dbc.messages.iter().find(|x| x.id.raw() == id)?;
|
||||||
let msg = match dbc.messages.iter().find(|x| x.id.raw() == id) {
|
let signals = &msg.signals;
|
||||||
Some(msg) => msg,
|
let mut signals_meta = Vec::<SignalMetadata>::new();
|
||||||
None => {
|
for signal in signals {
|
||||||
unknown_ids.insert(id);
|
let decimal_places = dbc
|
||||||
return;
|
.attribute_values_signal
|
||||||
}
|
|
||||||
};
|
|
||||||
let signals = &msg.signals;
|
|
||||||
let mut signals_meta = Vec::<SignalMetadata>::new();
|
|
||||||
for signal in signals {
|
|
||||||
let decimal_places = dbc
|
|
||||||
.attribute_values_signal
|
|
||||||
.iter()
|
|
||||||
.find(|x| {
|
|
||||||
x.message_id.raw() == id
|
|
||||||
&& x.signal_name == signal.name
|
|
||||||
&& x.name == "ld_decimal_places"
|
|
||||||
})
|
|
||||||
.map_or(default_decimal_places, |x| match x.value {
|
|
||||||
can_dbc::AttributeValue::Uint(val) => val,
|
|
||||||
_ => panic!("Bad ld_decimal_places value type, expected Uint."),
|
|
||||||
});
|
|
||||||
|
|
||||||
signals_meta.push(SignalMetadata {
|
|
||||||
shift: default_shift as u16,
|
|
||||||
scale: default_scale as u16,
|
|
||||||
decimal_places: decimal_places as u16,
|
|
||||||
name: signal.name.clone(),
|
|
||||||
unit: signal.unit.clone(),
|
|
||||||
signal: signal.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let frequency = dbc
|
|
||||||
.attribute_values_message
|
|
||||||
.iter()
|
.iter()
|
||||||
.find(|x| x.message_id.raw() == id && x.name == "Frequency")
|
.find(|x| {
|
||||||
.map_or(default_freqency, |x| match x.value {
|
x.message_id.raw() == id
|
||||||
|
&& x.signal_name == signal.name
|
||||||
|
&& x.name == "ld_decimal_places"
|
||||||
|
})
|
||||||
|
.map_or(default_decimal_places, |x| match x.value {
|
||||||
can_dbc::AttributeValue::Uint(val) => val,
|
can_dbc::AttributeValue::Uint(val) => val,
|
||||||
_ => panic!("Bad Frequency value type, expected Uint."),
|
_ => panic!("Bad ld_decimal_places value type, expected Uint."),
|
||||||
});
|
});
|
||||||
|
|
||||||
metadatas.insert(
|
signals_meta.push(SignalMetadata {
|
||||||
id,
|
shift: default_shift as u16,
|
||||||
MessageMetadata {
|
scale: default_scale as u16,
|
||||||
id,
|
decimal_places: decimal_places as u16,
|
||||||
frequency: frequency as u16,
|
name: signal.name.clone(),
|
||||||
signals: signals_meta,
|
unit: signal.unit.clone(),
|
||||||
},
|
signal: signal.clone(),
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let frequency = dbc
|
||||||
|
.attribute_values_message
|
||||||
|
.iter()
|
||||||
|
.find(|x| x.message_id.raw() == id && x.name == "Frequency")
|
||||||
|
.map_or(default_freqency, |x| match x.value {
|
||||||
|
can_dbc::AttributeValue::Uint(val) => val,
|
||||||
|
_ => panic!("Bad Frequency value type, expected Uint."),
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(MessageMetadata {
|
||||||
|
id,
|
||||||
|
frequency: frequency as u16,
|
||||||
|
signals: signals_meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_worker(
|
||||||
|
mmap: &[u8],
|
||||||
|
dbc: &Dbc,
|
||||||
|
) -> FrameOutput {
|
||||||
|
let mut channel_data = HashMap::<CanId, Vec<CanFrame>>::new();
|
||||||
|
let mut metadatas = HashMap::<CanId, MessageMetadata>::new();
|
||||||
|
|
||||||
|
let mut unknown_ids = HashSet::<CanId>::new();
|
||||||
|
let mut error_frames = Vec::<ErrorFrame>::new();
|
||||||
|
|
||||||
|
let mut add_metadata = |id| match build_message_metadata(dbc, id) {
|
||||||
|
Some(meta) => {
|
||||||
|
metadatas.insert(id, meta);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
unknown_ids.insert(id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Caller has aligned the chunk to line boundaries, so we always start at
|
// Caller has aligned the chunk to line boundaries, so we always start at
|
||||||
@@ -171,6 +175,51 @@ pub struct DecodeResult {
|
|||||||
pub info: DecodeProcessInfo,
|
pub info: DecodeProcessInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DecodeResult {
|
||||||
|
/// An empty result suitable for incrementally accumulating live CAN
|
||||||
|
/// frames via [`DecodeResult::push_live`]. The `info` fields that only
|
||||||
|
/// make sense for the file-based decode are zeroed.
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
DecodeResult {
|
||||||
|
data: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
info: DecodeProcessInfo {
|
||||||
|
unknown_ids: HashSet::new(),
|
||||||
|
error_frames: Vec::new(),
|
||||||
|
date: chrono::DateTime::from_timestamp(0, 0).unwrap().naive_utc(),
|
||||||
|
thread_count: 0,
|
||||||
|
page_size: 0,
|
||||||
|
pages_per_chunk: 0,
|
||||||
|
chunk_size: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a single live frame, lazily resolving its metadata from the DBC
|
||||||
|
/// the first time an id is seen. Frames are expected to arrive in
|
||||||
|
/// non-decreasing timestamp order (as they do off a live bus), so no
|
||||||
|
/// re-sorting is performed — `compute_signal` consumes them in order.
|
||||||
|
pub fn push_live(&mut self, dbc: &Dbc, frame: CanFrame) {
|
||||||
|
match self.data.get_mut(&frame.can_id) {
|
||||||
|
Some(vec) => vec.push(frame),
|
||||||
|
None => {
|
||||||
|
let id = frame.can_id;
|
||||||
|
self.data.insert(id, vec![frame]);
|
||||||
|
if !self.metadata.contains_key(&id) {
|
||||||
|
match build_message_metadata(dbc, id) {
|
||||||
|
Some(meta) => {
|
||||||
|
self.metadata.insert(id, meta);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.info.unknown_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn decode(dbc: &Dbc,
|
pub fn decode(dbc: &Dbc,
|
||||||
asc_path: &Path) -> DecodeResult {
|
asc_path: &Path) -> DecodeResult {
|
||||||
let thread_count = std::thread::available_parallelism().unwrap().get();
|
let thread_count = std::thread::available_parallelism().unwrap().get();
|
||||||
@@ -255,6 +304,11 @@ pub fn decode(dbc: &Dbc,
|
|||||||
// println!("{:?}", unknown_ids);
|
// println!("{:?}", unknown_ids);
|
||||||
// println!("{:?}", error_frames);
|
// println!("{:?}", error_frames);
|
||||||
|
|
||||||
|
for (_, frames) in &mut data {
|
||||||
|
// frames.voracious_mt_sort(thread_count);
|
||||||
|
frames.voracious_sort();
|
||||||
|
}
|
||||||
|
|
||||||
DecodeResult {
|
DecodeResult {
|
||||||
data,
|
data,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -274,7 +328,7 @@ pub fn decode_to_motec(dbc: &Dbc,
|
|||||||
asc_path: &Path,
|
asc_path: &Path,
|
||||||
output_dir: &Path) -> DecodeProcessInfo {
|
output_dir: &Path) -> DecodeProcessInfo {
|
||||||
let DecodeResult {
|
let DecodeResult {
|
||||||
mut data,
|
data,
|
||||||
metadata,
|
metadata,
|
||||||
info,
|
info,
|
||||||
} = decode(dbc, asc_path);
|
} = decode(dbc, asc_path);
|
||||||
@@ -285,11 +339,6 @@ pub fn decode_to_motec(dbc: &Dbc,
|
|||||||
.and_utc()
|
.and_utc()
|
||||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||||
|
|
||||||
for (_, frames) in &mut data {
|
|
||||||
// frames.voracious_mt_sort(thread_count);
|
|
||||||
frames.voracious_sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
let msgs: Vec<MessageMetadata> = metadata.values().cloned().collect();
|
let msgs: Vec<MessageMetadata> = metadata.values().cloned().collect();
|
||||||
|
|
||||||
// println!("Messages: {}", msgs.len());
|
// println!("Messages: {}", msgs.len());
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ pub fn get_metadata_total_count(
|
|||||||
/// `bytes_le`). Mirrors `calc_bits` + `calc_signed_bits` + factor/offset in
|
/// `bytes_le`). Mirrors `calc_bits` + `calc_signed_bits` + factor/offset in
|
||||||
/// _ref_lib/src/process.cc.
|
/// _ref_lib/src/process.cc.
|
||||||
#[inline]
|
#[inline]
|
||||||
fn extract_signal(bytes_le: u64, sig: &Signal) -> f64 {
|
pub fn extract_signal(bytes_le: u64, sig: &Signal) -> f64 {
|
||||||
let size = sig.size;
|
let size = sig.size;
|
||||||
let shifted = match sig.byte_order {
|
let shifted = match sig.byte_order {
|
||||||
ByteOrder::BigEndian => bytes_le.swap_bytes() >> (64 - size - sig.start_bit),
|
ByteOrder::BigEndian => bytes_le.swap_bytes() >> (64 - size - sig.start_bit),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "svldplotter"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "svldplotter"
|
||||||
|
test = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libsvld = { path = "../libsvld" }
|
||||||
|
can-dbc = "9.1"
|
||||||
|
|
||||||
|
eframe = {
|
||||||
|
version = "0.34",
|
||||||
|
features = ["persistence"]
|
||||||
|
}
|
||||||
|
egui = "0.34"
|
||||||
|
egui_plot = "0.35"
|
||||||
|
egui_ltreeview = "0.7"
|
||||||
|
fuzzy-matcher = "0.3"
|
||||||
|
rfd = "0.17"
|
||||||
|
socketcan = "3.6.1"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Live CAN bus source (Linux/SocketCAN).
|
||||||
|
//!
|
||||||
|
//! Opens a SocketCAN interface and spawns a background thread that reads CAN
|
||||||
|
//! frames, converting each into the same `libsvld::can_frame::CanFrame` the
|
||||||
|
//! ASC decoder produces. Frames are streamed back over an `mpsc` channel so
|
||||||
|
//! the UI thread can drain them without blocking. Timestamps are seconds
|
||||||
|
//! elapsed since the listener was opened, mirroring the relative timebase the
|
||||||
|
//! ASC files use.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{channel, Receiver};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use libsvld::can_frame::{CanFrame, Direction};
|
||||||
|
use socketcan::{CanFrame as ScFrame, EmbeddedFrame, Frame, Socket};
|
||||||
|
|
||||||
|
/// Open `iface` (e.g. `"can0"`) and start streaming frames.
|
||||||
|
///
|
||||||
|
/// Returns a receiver that yields decoded [`CanFrame`]s until the interface
|
||||||
|
/// errors out or the receiver is dropped. The reader runs on its own thread.
|
||||||
|
pub fn spawn_reader(iface: &str) -> std::io::Result<Receiver<CanFrame>> {
|
||||||
|
let socket = socketcan::CanSocket::open(iface)?;
|
||||||
|
let (tx, rx) = channel::<CanFrame>();
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
let frame = match socket.read_frame() {
|
||||||
|
Ok(frame) => frame,
|
||||||
|
// A read error (interface down, etc.) ends the stream.
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only data frames carry signal payloads; ignore remote/error
|
||||||
|
// frames for plotting purposes.
|
||||||
|
let data_frame = match frame {
|
||||||
|
ScFrame::Data(d) => d,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = data_frame.data();
|
||||||
|
let dlc = payload.len().min(8);
|
||||||
|
let mut data = [0u8; 8];
|
||||||
|
data[..dlc].copy_from_slice(&payload[..dlc]);
|
||||||
|
|
||||||
|
let cf = CanFrame {
|
||||||
|
timestamp: start.elapsed().as_secs_f64(),
|
||||||
|
channel: 0,
|
||||||
|
can_id: data_frame.raw_id(),
|
||||||
|
direction: Direction::Rx,
|
||||||
|
dlc: dlc as u8,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Receiver dropped → UI no longer interested, stop reading.
|
||||||
|
if tx.send(cf).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(rx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
mod can_live;
|
||||||
|
mod plot;
|
||||||
|
mod plot_list;
|
||||||
|
mod signal;
|
||||||
|
mod tree;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{mpsc::{channel, Receiver}, Arc},
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
|
use can_dbc::Dbc;
|
||||||
|
use eframe::egui;
|
||||||
|
use libsvld::{can_frame::CanFrame, DecodeResult};
|
||||||
|
|
||||||
|
use crate::plot::show_plot_panel;
|
||||||
|
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_delay_signal, compute_signal, resolve_signal};
|
||||||
|
use crate::tree::{
|
||||||
|
build_tree, decode_signal_node_id, delay_node_id, show_dbc_tree, MsgNode, SignalAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let native_options = eframe::NativeOptions::default();
|
||||||
|
eframe::run_native(
|
||||||
|
"svldplotter",
|
||||||
|
native_options,
|
||||||
|
Box::new(|cc| {
|
||||||
|
let mut app = MyEguiApp::default();
|
||||||
|
// Optional CLI quick-load. Positional args are an optional DBC and
|
||||||
|
// ASC, intended for dev iteration:
|
||||||
|
// svldplotter [DBC [ASC]]
|
||||||
|
// Passing `--can <iface>` (e.g. `svldplotter car.dbc --can can0`)
|
||||||
|
// switches to live mode: instead of reading an ASC file the
|
||||||
|
// plotter streams frames straight off the CAN bus and plots them
|
||||||
|
// in real time, using the DBC for signal metadata.
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let mut positional: Vec<String> = Vec::new();
|
||||||
|
let mut can_iface: Option<String> = None;
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--can" | "-c" => {
|
||||||
|
can_iface = args.get(i + 1).cloned();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
positional.push(other.to_string());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(dbc) = positional.first() {
|
||||||
|
app.load_dbc(PathBuf::from(dbc));
|
||||||
|
}
|
||||||
|
if let Some(iface) = can_iface {
|
||||||
|
// Live CAN takes precedence over any ASC argument.
|
||||||
|
app.start_can_live(&iface, &cc.egui_ctx);
|
||||||
|
} else if let Some(asc) = positional.get(1) {
|
||||||
|
app.asc_paths.push(PathBuf::from(asc));
|
||||||
|
app.set_active_asc(app.asc_paths.len() - 1, Some(&cc.egui_ctx));
|
||||||
|
}
|
||||||
|
Ok(Box::new(app))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, PartialEq, Eq)]
|
||||||
|
enum LeftTab {
|
||||||
|
#[default]
|
||||||
|
Signals,
|
||||||
|
Files,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DecodeJob {
|
||||||
|
asc_index: usize,
|
||||||
|
asc_path: PathBuf,
|
||||||
|
receiver: Receiver<Arc<DecodeResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MyEguiApp {
|
||||||
|
dbc: Option<Arc<Dbc>>,
|
||||||
|
dbc_path: Option<PathBuf>,
|
||||||
|
asc_paths: Vec<PathBuf>,
|
||||||
|
active_asc: Option<usize>,
|
||||||
|
data: Option<Arc<DecodeResult>>,
|
||||||
|
decode_cache: HashMap<PathBuf, Arc<DecodeResult>>,
|
||||||
|
pending: Option<DecodeJob>,
|
||||||
|
tree: Vec<MsgNode>,
|
||||||
|
filter: String,
|
||||||
|
plots: Vec<PlotGroup>,
|
||||||
|
next_plot_id: u64,
|
||||||
|
cursor_x: Option<f64>,
|
||||||
|
left_tab: LeftTab,
|
||||||
|
/// Live CAN stream receiver, present only in `--can` mode.
|
||||||
|
live_rx: Option<Receiver<CanFrame>>,
|
||||||
|
/// Growing decode result fed by the live CAN stream.
|
||||||
|
live_data: Option<DecodeResult>,
|
||||||
|
/// Latest data x-extent from the previous frame, used to detect whether
|
||||||
|
/// the live view is pinned to the right edge for auto-scroll.
|
||||||
|
live_prev_max: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyEguiApp {
|
||||||
|
fn load_dbc(&mut self, path: PathBuf) {
|
||||||
|
let Ok(text) = fs::read_to_string(&path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(dbc) = Dbc::try_from(text.as_str()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.dbc = Some(Arc::new(dbc));
|
||||||
|
self.dbc_path = Some(path);
|
||||||
|
// DBC change invalidates the decode cache.
|
||||||
|
self.decode_cache.clear();
|
||||||
|
self.pending = None;
|
||||||
|
self.data = None;
|
||||||
|
self.tree.clear();
|
||||||
|
if let Some(idx) = self.active_asc {
|
||||||
|
self.start_or_use_cached(idx, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_asc(&mut self, path: PathBuf) {
|
||||||
|
self.asc_paths.push(path);
|
||||||
|
if self.active_asc.is_none() {
|
||||||
|
self.set_active_asc(self.asc_paths.len() - 1, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to a given ASC. Use the cached DecodeResult if present;
|
||||||
|
/// otherwise spawn a background decode and show progress via the
|
||||||
|
/// `pending` job.
|
||||||
|
fn set_active_asc(&mut self, idx: usize, ctx: Option<&egui::Context>) {
|
||||||
|
if idx >= self.asc_paths.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.active_asc = Some(idx);
|
||||||
|
self.start_or_use_cached(idx, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_or_use_cached(&mut self, idx: usize, ctx: Option<&egui::Context>) {
|
||||||
|
let Some(dbc) = self.dbc.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path = self.asc_paths[idx].clone();
|
||||||
|
if self.decode_cache.contains_key(&path) {
|
||||||
|
self.activate_decoded(idx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
let dbc_for_thread = dbc;
|
||||||
|
let path_for_thread = path.clone();
|
||||||
|
let ctx_for_thread = ctx.cloned();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = libsvld::decode(&dbc_for_thread, &path_for_thread);
|
||||||
|
let _ = tx.send(Arc::new(result));
|
||||||
|
if let Some(ctx) = ctx_for_thread {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.pending = Some(DecodeJob {
|
||||||
|
asc_index: idx,
|
||||||
|
asc_path: path,
|
||||||
|
receiver: rx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull a finished decode out of `pending` and install it.
|
||||||
|
fn poll_pending(&mut self) {
|
||||||
|
let Some(job) = self.pending.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(result) = job.receiver.try_recv() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let asc_index = job.asc_index;
|
||||||
|
let asc_path = job.asc_path.clone();
|
||||||
|
self.pending = None;
|
||||||
|
self.decode_cache.insert(asc_path, result);
|
||||||
|
if self.active_asc == Some(asc_index) {
|
||||||
|
self.activate_decoded(asc_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point `self.data`/`self.tree` at the cached result for `idx` and
|
||||||
|
/// re-resolve every plotted signal against the new data.
|
||||||
|
fn activate_decoded(&mut self, idx: usize) {
|
||||||
|
let Some(dbc) = self.dbc.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path = &self.asc_paths[idx];
|
||||||
|
let Some(data) = self.decode_cache.get(path).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.tree = build_tree(dbc, &data);
|
||||||
|
// Re-resolve plotted signals so existing plots survive an ASC switch.
|
||||||
|
for plot in &mut self.plots {
|
||||||
|
for sig in &mut plot.signals {
|
||||||
|
if let Some(new_sig) = resolve_signal(&data, sig.id) {
|
||||||
|
*sig = new_sig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.data = Some(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter live CAN mode: open `iface` and start streaming frames into a
|
||||||
|
/// fresh, growing `DecodeResult`. Requires a DBC to already be loaded so
|
||||||
|
/// incoming frames can be resolved to signals.
|
||||||
|
fn start_can_live(&mut self, iface: &str, ctx: &egui::Context) {
|
||||||
|
if self.dbc.is_none() {
|
||||||
|
eprintln!("Cannot start live CAN: no DBC loaded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match can_live::spawn_reader(iface) {
|
||||||
|
Ok(rx) => {
|
||||||
|
self.live_rx = Some(rx);
|
||||||
|
self.live_data = Some(DecodeResult::empty());
|
||||||
|
// Clear any file-based state so the two sources don't mix.
|
||||||
|
self.active_asc = None;
|
||||||
|
self.data = None;
|
||||||
|
self.tree.clear();
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to open CAN interface '{iface}': {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain any frames received from the live CAN stream, fold them into the
|
||||||
|
/// live `DecodeResult`, and re-resolve the tree and plotted signals so the
|
||||||
|
/// UI reflects the latest data. A repaint is always requested while live
|
||||||
|
/// so polling continues even when no frame arrived this tick.
|
||||||
|
fn poll_live(&mut self, ctx: &egui::Context) {
|
||||||
|
if self.live_rx.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep the UI ticking so we keep polling the stream.
|
||||||
|
ctx.request_repaint();
|
||||||
|
|
||||||
|
let Some(dbc) = self.dbc.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect first so the receiver borrow is released before we mutate
|
||||||
|
// `self.live_data` below.
|
||||||
|
let frames: Vec<CanFrame> = self
|
||||||
|
.live_rx
|
||||||
|
.as_ref()
|
||||||
|
.map(|rx| rx.try_iter().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if frames.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(live) = self.live_data.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for frame in frames {
|
||||||
|
live.push_live(&dbc, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-resolve tree and plots against the updated live data. These touch
|
||||||
|
// disjoint fields of `self`, so the borrows don't conflict.
|
||||||
|
let data = self.live_data.as_ref().unwrap();
|
||||||
|
self.tree = build_tree(&dbc, data);
|
||||||
|
for plot in &mut self.plots {
|
||||||
|
for sig in &mut plot.signals {
|
||||||
|
if let Some(new_sig) = resolve_signal(data, sig.id) {
|
||||||
|
*sig = new_sig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_signal_actions(&mut self, actions: Vec<SignalAction>) {
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
SignalAction::Toggle(id) => self.toggle_plotted(id, false),
|
||||||
|
SignalAction::PlotDelay(sig_id) => {
|
||||||
|
// The delay plot uses a distinct node id derived from the
|
||||||
|
// signal, so it can coexist with the value plot.
|
||||||
|
self.toggle_plotted(delay_node_id(sig_id), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle whether `id` is plotted. `delay` selects the inter-frame delay
|
||||||
|
/// (Δt) computation instead of the signal's value.
|
||||||
|
fn toggle_plotted(&mut self, id: u64, delay: bool) {
|
||||||
|
if contains_signal(&self.plots, id) {
|
||||||
|
remove_signal(&mut self.plots, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((msg_id, sig_idx)) = decode_signal_node_id(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Pull from the live stream when in `--can` mode, otherwise the decoded
|
||||||
|
// ASC. Accessed as disjoint fields so the immutable data borrow doesn't
|
||||||
|
// clash with the `self.plots` mutation.
|
||||||
|
let data = if self.live_data.is_some() {
|
||||||
|
self.live_data.as_ref()
|
||||||
|
} else {
|
||||||
|
self.data.as_deref()
|
||||||
|
};
|
||||||
|
let Some(data) = data else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let sig = if delay {
|
||||||
|
compute_delay_signal(data, msg_id, sig_idx, id)
|
||||||
|
} else {
|
||||||
|
compute_signal(data, msg_id, sig_idx, id)
|
||||||
|
};
|
||||||
|
if let Some(sig) = 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FilesAction {
|
||||||
|
LoadDbc(PathBuf),
|
||||||
|
AddAsc(PathBuf),
|
||||||
|
SetActive(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_files_tab(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
dbc_path: Option<&PathBuf>,
|
||||||
|
asc_paths: &[PathBuf],
|
||||||
|
active: Option<usize>,
|
||||||
|
) -> Vec<FilesAction> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Load DBC").clicked() {
|
||||||
|
if let Some(p) = rfd::FileDialog::new()
|
||||||
|
.add_filter("DBC files", &["dbc"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
actions.push(FilesAction::LoadDbc(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let label = dbc_path
|
||||||
|
.map(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default())
|
||||||
|
.unwrap_or_else(|| "(none)".to_string());
|
||||||
|
ui.label(label);
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Add ASC").clicked() {
|
||||||
|
if let Some(p) = rfd::FileDialog::new()
|
||||||
|
.add_filter("ASC files", &["asc"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
actions.push(FilesAction::AddAsc(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.label(format!("{} file(s)", asc_paths.len()));
|
||||||
|
});
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
for (i, path) in asc_paths.iter().enumerate() {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.display().to_string());
|
||||||
|
if ui.selectable_label(active == Some(i), name).clicked() {
|
||||||
|
actions.push(FilesAction::SetActive(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for MyEguiApp {
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
self.poll_pending();
|
||||||
|
|
||||||
|
let ctx = ui.ctx().clone();
|
||||||
|
self.poll_live(&ctx);
|
||||||
|
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, files_actions) = egui::CentralPanel::default()
|
||||||
|
.show_inside(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.left_tab,
|
||||||
|
LeftTab::Signals,
|
||||||
|
"Signals",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.left_tab,
|
||||||
|
LeftTab::Files,
|
||||||
|
"Files",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
match self.left_tab {
|
||||||
|
LeftTab::Signals => (
|
||||||
|
show_dbc_tree(ui, &mut self.filter, &self.tree),
|
||||||
|
Vec::new(),
|
||||||
|
),
|
||||||
|
LeftTab::Files => (
|
||||||
|
Vec::new(),
|
||||||
|
show_files_tab(
|
||||||
|
ui,
|
||||||
|
self.dbc_path.as_ref(),
|
||||||
|
&self.asc_paths,
|
||||||
|
self.active_asc,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
self.handle_signal_actions(toggles);
|
||||||
|
self.handle_plot_actions(plot_actions);
|
||||||
|
for action in files_actions {
|
||||||
|
match action {
|
||||||
|
FilesAction::LoadDbc(p) => self.load_dbc(p),
|
||||||
|
FilesAction::AddAsc(p) => self.add_asc(p),
|
||||||
|
FilesAction::SetActive(i) => self.set_active_asc(i, Some(&ctx)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
show_plot_panel(
|
||||||
|
ui,
|
||||||
|
&self.plots,
|
||||||
|
&mut self.cursor_x,
|
||||||
|
self.live_rx.is_some(),
|
||||||
|
&mut self.live_prev_max,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(job) = self.pending.as_ref() {
|
||||||
|
let name = job
|
||||||
|
.asc_path
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| job.asc_path.display().to_string());
|
||||||
|
egui::Modal::new(egui::Id::new("DecodingModal")).show(&ctx, |ui| {
|
||||||
|
ui.set_min_width(280.0);
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.heading("Decoding...");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.label(name);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.spinner();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
use egui::ecolor::Hsva;
|
||||||
|
use egui::{Color32, Vec2b};
|
||||||
|
use egui_plot::{AxisHints, Legend, Line, Plot, PlotBounds, 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<PlotPoint>, threshold: f64) -> Vec<Vec<PlotPoint>> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut current = Vec::new();
|
||||||
|
let mut prev_x: Option<f64> = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_x_range(plots: &[PlotGroup]) -> (f64, f64) {
|
||||||
|
let signals = plots.iter().flat_map(|p| p.signals.iter());
|
||||||
|
let min = signals
|
||||||
|
.clone()
|
||||||
|
.filter_map(|s| s.points.first().map(|p| p.x))
|
||||||
|
.fold(f64::INFINITY, f64::min);
|
||||||
|
let max = signals
|
||||||
|
.filter_map(|s| s.points.last().map(|p| p.x))
|
||||||
|
.fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
(min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp the visible x-range to [0, x_max]. Preserves the current zoom
|
||||||
|
/// width when possible; if the zoomed-out width exceeds the data range,
|
||||||
|
/// just snaps to the full range.
|
||||||
|
///
|
||||||
|
/// In live mode, if the view's right edge was pinned to the latest sample
|
||||||
|
/// (`prev_max`, the data extent from the previous frame), the window is
|
||||||
|
/// scrolled right to keep the right edge at the new `x_max` so the plot
|
||||||
|
/// auto-follows incoming data. Panning away from the edge disengages this.
|
||||||
|
fn clamp_x_bounds(
|
||||||
|
plot_ui: &mut egui_plot::PlotUi,
|
||||||
|
x_max: f64,
|
||||||
|
live: bool,
|
||||||
|
prev_max: Option<f64>,
|
||||||
|
) {
|
||||||
|
if !x_max.is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bounds = plot_ui.plot_bounds();
|
||||||
|
let lo = bounds.min();
|
||||||
|
let hi = bounds.max();
|
||||||
|
let mut new_lo_x = lo[0];
|
||||||
|
let mut new_hi_x = hi[0];
|
||||||
|
let width = new_hi_x - new_lo_x;
|
||||||
|
|
||||||
|
if live && width < x_max {
|
||||||
|
// Pinned when the right edge sits at (or past) the previous extent.
|
||||||
|
// A tiny epsilon absorbs float error from last frame's snap.
|
||||||
|
let pinned = match prev_max {
|
||||||
|
Some(p) => new_hi_x >= p - width * 1e-3,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if pinned {
|
||||||
|
new_hi_x = x_max;
|
||||||
|
new_lo_x = x_max - width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if width >= x_max {
|
||||||
|
new_lo_x = 0.0;
|
||||||
|
new_hi_x = x_max;
|
||||||
|
} else {
|
||||||
|
if new_lo_x < 0.0 {
|
||||||
|
new_lo_x = 0.0;
|
||||||
|
new_hi_x = new_lo_x + width;
|
||||||
|
}
|
||||||
|
if new_hi_x > x_max {
|
||||||
|
new_hi_x = x_max;
|
||||||
|
new_lo_x = new_hi_x - width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_lo_x != lo[0] || new_hi_x != hi[0] {
|
||||||
|
plot_ui.set_plot_bounds(PlotBounds::from_min_max(
|
||||||
|
[new_lo_x, lo[1]],
|
||||||
|
[new_hi_x, hi[1]],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_plot_panel(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
plots: &[PlotGroup],
|
||||||
|
cursor_x: &mut Option<f64>,
|
||||||
|
live: bool,
|
||||||
|
prev_max: &mut Option<f64>,
|
||||||
|
) {
|
||||||
|
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||||
|
if plots.is_empty() {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.label("Click a signal in the tree to plot it.");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (full_min, full_max) = full_x_range(plots);
|
||||||
|
// Snapshot the previous frame's extent for the auto-follow decision,
|
||||||
|
// then record this frame's for the next one.
|
||||||
|
let follow_prev_max = *prev_max;
|
||||||
|
if full_max.is_finite() {
|
||||||
|
*prev_max = Some(full_max);
|
||||||
|
}
|
||||||
|
let link_group = ui.id().with("plot-link");
|
||||||
|
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<f64> = None;
|
||||||
|
|
||||||
|
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<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 unit = majority_unit(&plot_group.signals).unwrap_or("empty");
|
||||||
|
|
||||||
|
let plot = Plot::new(("plot", plot_group.id))
|
||||||
|
.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| {
|
||||||
|
clamp_x_bounds(plot_ui, full_max, live, follow_prev_max);
|
||||||
|
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 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.
|
||||||
|
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);
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
if let Some(x) = response.inner {
|
||||||
|
new_cursor_x = Some(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*cursor_x = new_cursor_x;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep at most one empty plot; drop any further empties (keeps the
|
||||||
|
/// first one encountered so the user's stable reference doesn't vanish).
|
||||||
|
fn dedupe_empty(plots: &mut Vec<PlotGroup>) {
|
||||||
|
let mut seen_empty = false;
|
||||||
|
plots.retain(|p| {
|
||||||
|
if p.signals.is_empty() {
|
||||||
|
if seen_empty {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
seen_empty = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 Vec<PlotGroup>, signal_id: u64) {
|
||||||
|
for plot in plots.iter_mut() {
|
||||||
|
if let Some(pos) = plot.signals.iter().position(|s| s.id == signal_id) {
|
||||||
|
plot.signals.remove(pos);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dedupe_empty(plots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
// Prefer an existing empty plot, then a unit-matching plot under cap.
|
||||||
|
let slot = plots
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.signals.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
plots.iter().position(|p| {
|
||||||
|
p.signals.len() < MAX_SIGNALS_PER_PLOT
|
||||||
|
&& majority_unit(&p.signals) == Some(unit)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if let Some(i) = slot {
|
||||||
|
plots[i].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) {
|
||||||
|
if plots.iter().any(|p| p.signals.is_empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 node = NodeBuilder::dir(plot_node_id(plot.id))
|
||||||
|
.label(label)
|
||||||
|
.activatable(true)
|
||||||
|
.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 {
|
||||||
|
match action {
|
||||||
|
Action::Move(dd) => {
|
||||||
|
events.borrow_mut().push(PlotListAction::Move(dd));
|
||||||
|
}
|
||||||
|
Action::Activate(act) => {
|
||||||
|
for id in act.selected {
|
||||||
|
if id & SIGNAL_FLAG != 0 {
|
||||||
|
events
|
||||||
|
.borrow_mut()
|
||||||
|
.push(PlotListAction::RemoveSignal(id));
|
||||||
|
} else if let Some(plot_id) = decode_plot_node_id(id) {
|
||||||
|
events
|
||||||
|
.borrow_mut()
|
||||||
|
.push(PlotListAction::RemovePlot(plot_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dedupe_empty(plots);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
use egui_plot::PlotPoint;
|
||||||
|
use libsvld::DecodeResult;
|
||||||
|
|
||||||
|
use crate::tree::{decode_signal_node_id, is_delay_node_id};
|
||||||
|
|
||||||
|
pub struct PlottedSignal {
|
||||||
|
pub id: u64,
|
||||||
|
pub label: String,
|
||||||
|
pub unit: String,
|
||||||
|
pub points: Vec<PlotPoint>,
|
||||||
|
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<f64> =
|
||||||
|
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(
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let gap_threshold = compute_gap_threshold(&points);
|
||||||
|
Some(PlottedSignal {
|
||||||
|
id,
|
||||||
|
label: sig.name.clone(),
|
||||||
|
unit: sig.unit.clone(),
|
||||||
|
points,
|
||||||
|
gap_threshold,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a signal whose value is the time delay between consecutive CAN
|
||||||
|
/// frames of a message (Δt, in seconds), keyed off one of its signals.
|
||||||
|
/// The first frame has no predecessor, so it produces no point. Plotted at
|
||||||
|
/// the timestamp of the later frame.
|
||||||
|
pub fn compute_delay_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().saturating_sub(1));
|
||||||
|
let mut prev: Option<f64> = None;
|
||||||
|
for frame in frames {
|
||||||
|
if let Some(p) = prev {
|
||||||
|
points.push(PlotPoint {
|
||||||
|
x: frame.timestamp,
|
||||||
|
y: frame.timestamp - p,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prev = Some(frame.timestamp);
|
||||||
|
}
|
||||||
|
let gap_threshold = compute_gap_threshold(&points);
|
||||||
|
Some(PlottedSignal {
|
||||||
|
id,
|
||||||
|
label: format!("{} Δt", sig.name),
|
||||||
|
unit: "s".to_string(),
|
||||||
|
points,
|
||||||
|
gap_threshold,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a plotted-signal node id against decoded data, dispatching to the
|
||||||
|
/// value or frame-delay computation based on the id's flags. Used to (re)build
|
||||||
|
/// a `PlottedSignal` whenever the underlying data changes (ASC switch, live).
|
||||||
|
pub fn resolve_signal(data: &DecodeResult, id: u64) -> Option<PlottedSignal> {
|
||||||
|
let (msg_id, sig_idx) = decode_signal_node_id(id)?;
|
||||||
|
if is_delay_node_id(id) {
|
||||||
|
compute_delay_signal(data, msg_id, sig_idx, id)
|
||||||
|
} else {
|
||||||
|
compute_signal(data, msg_id, sig_idx, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use can_dbc::Dbc;
|
||||||
|
use eframe::egui;
|
||||||
|
use egui::ScrollArea;
|
||||||
|
use egui_ltreeview::{Action, NodeBuilder, TreeView, TreeViewBuilder};
|
||||||
|
use fuzzy_matcher::FuzzyMatcher;
|
||||||
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node-id encoding shared across the two tree views.
|
||||||
|
// bit 63 set → signal leaf
|
||||||
|
// bit 62 set → plot directory (in the lower "plots" tree)
|
||||||
|
// bit 61 set → "frame delay" variant of a signal (keeps bit 63 set too, so
|
||||||
|
// it still behaves like a signal for selection / drag-and-drop)
|
||||||
|
// 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 DELAY_FLAG: u64 = 1 << 61;
|
||||||
|
pub const ROOT_NODE_ID: u64 = 1;
|
||||||
|
|
||||||
|
/// User actions emitted by the DBC signal tree.
|
||||||
|
pub enum SignalAction {
|
||||||
|
/// Toggle plotting of a signal's value (left-click).
|
||||||
|
Toggle(u64),
|
||||||
|
/// Toggle plotting of the inter-frame delay (Δt) for a signal's message
|
||||||
|
/// (right-click → context menu). The id is the plain signal node id.
|
||||||
|
PlotDelay(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the "frame delay" node id for a plain signal node id.
|
||||||
|
pub fn delay_node_id(signal_id: u64) -> u64 {
|
||||||
|
signal_id | DELAY_FLAG
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a node id refers to the inter-frame delay variant of a signal.
|
||||||
|
pub fn is_delay_node_id(id: u64) -> bool {
|
||||||
|
id & DELAY_FLAG != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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> {
|
||||||
|
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 DBC tree and return the user's actions: left-clicking a signal
|
||||||
|
/// toggles its value plot; right-clicking offers a "plot frame delay" entry.
|
||||||
|
pub fn show_dbc_tree(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
filter: &mut String,
|
||||||
|
tree: &[MsgNode],
|
||||||
|
) -> Vec<SignalAction> {
|
||||||
|
let events: RefCell<Vec<SignalAction>> = RefCell::new(Vec::new());
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(filter)
|
||||||
|
.hint_text("Filter messages/signals")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
let needle = filter.trim();
|
||||||
|
let matcher = SkimMatcherV2::default().ignore_case();
|
||||||
|
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"))
|
||||||
|
.allow_drag_and_drop(false)
|
||||||
|
.show(ui, |builder: &mut TreeViewBuilder<'_, u64>| {
|
||||||
|
for msg in tree {
|
||||||
|
let msg_hit = needle.is_empty()
|
||||||
|
|| matcher.fuzzy_match(&msg.label, needle).is_some();
|
||||||
|
let matching: Vec<&SigNode> = if msg_hit {
|
||||||
|
msg.signals.iter().collect()
|
||||||
|
} else {
|
||||||
|
msg.signals
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
matcher.fuzzy_match(&s.name, needle).is_some()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if !msg_hit && matching.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
builder.dir(msg.id, msg.label.as_str());
|
||||||
|
for sig in matching {
|
||||||
|
let sig_id = sig.id;
|
||||||
|
let node = NodeBuilder::leaf(sig.id)
|
||||||
|
.label(sig.name.as_str())
|
||||||
|
.context_menu(|ui| {
|
||||||
|
if ui
|
||||||
|
.button("Plot frame delay (Δt)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
events.borrow_mut().push(
|
||||||
|
SignalAction::PlotDelay(sig_id),
|
||||||
|
);
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
builder.node(node);
|
||||||
|
}
|
||||||
|
builder.close_dir();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for action in actions {
|
||||||
|
if let Action::SetSelected(ids) = action {
|
||||||
|
for id in ids {
|
||||||
|
if id & SIGNAL_FLAG != 0 {
|
||||||
|
events.borrow_mut().push(SignalAction::Toggle(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
events.into_inner()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user