diff --git a/README.md b/README.md index 03a6e7b..73a7d1e 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,40 @@ Get-FileHash .\SerialGUI_rs-x86_64-pc-windows-msvc.exe -Algorithm SHA256 2. Select the serial port and configure the parameters according to your needs. 3. Start monitoring the serial communication. +## Graph Mode + +SerialGUI-Rs now includes a graph visualization feature for displaying numeric data in real-time. + +### How to Use Graph Mode + +1. Enable the chart panel by checking "Show chart panel" in the top menu bar +2. Send numeric data in a comma-separated format through your serial device +3. Watch as your data is automatically plotted on the chart + +### Supported Data Format + +The graph mode accepts data in the following format: +``` +value1,value2,value3,value4,... +``` + +**Examples:** +- Simple integers: `1,2,3,4,5,6` +- Decimal values: `1.5,2.7,3.2,4.8` +- Mixed data: `10,25.5,30,15.2` +- Negative numbers: `-1,2,-3.5,4` + +### Features + +- **Real-time plotting**: Data points appear immediately as they arrive +- **Auto-scaling**: The Y-axis automatically adjusts to fit your data range +- **Streaming support**: Works with continuous data streams +- **Fragmentation handling**: Correctly processes data split across multiple packets +- **Memory efficient**: Limits data history to 1000 samples to prevent memory issues +- **Grid lines**: Visual reference lines help interpret the data values +- **Axis labels**: Clear X and Y axis labels showing data values and sample indices + + ## Contribution Thank you for considering contributing to **SerialGUI-Rs**! Here are some basic rules for contributing to the project, following the guidelines of GNU projects: diff --git a/assets/Screenshot.png b/assets/Screenshot.png index ab70837..bfbf85b 100644 Binary files a/assets/Screenshot.png and b/assets/Screenshot.png differ diff --git a/src/app.rs b/src/app.rs index 87c3701..37bbba9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crate::communicationtrait::{CommunicationEvent, CommunicationManager}; use crate::generalsettings::AppSettings; -use crate::gui::{ConnectionPanel, FileLogPanel, MenuBar, RxPanel, SendPanel}; +use crate::gui::{ChartPanel, ConnectionPanel, FileLogPanel, MenuBar, RxPanel, SendPanel}; use crate::serial_impl::SerialCommunication; use std::sync::{mpsc, Arc, Mutex}; @@ -16,6 +16,7 @@ pub struct TemplateApp { #[serde(skip)] connection_panel: ConnectionPanel, rx_panel: RxPanel, + chart_panel: ChartPanel, #[serde(skip)] menu_bar: MenuBar, send_panel: SendPanel, @@ -45,6 +46,7 @@ impl Default for TemplateApp { settings: settings.clone(), connection_panel: ConnectionPanel::new(), rx_panel: RxPanel::new(settings.max_log_string_length), + chart_panel: ChartPanel::new(settings.max_log_string_length), menu_bar: MenuBar::new(), send_panel: SendPanel::new(), file_log_panel: FileLogPanel::new(default_filename), @@ -128,6 +130,9 @@ impl TemplateApp { }; self.write_log(&message); self.file_log_panel.write_to_file(&data); + if self.settings.show_chart_panel { + self.chart_panel.process_rx(data); + } ctx.request_repaint(); } CommunicationEvent::ConnectionClosed => { @@ -164,10 +169,22 @@ impl eframe::App for TemplateApp { egui::CentralPanel::default().show(ctx, |ui| { let available_size = ui.available_size(); + let mut chart_area = available_size; + if self.settings.show_text_panel && self.settings.show_chart_panel { + chart_area.y *= 0.5; + } + + if self.settings.show_chart_panel { + self.chart_panel + .show(ui, chart_area, self.settings.auto_scroll_log); + ui.separator(); + } - self.rx_panel - .show(ui, available_size, self.settings.auto_scroll_log); - ui.separator(); + if self.settings.show_text_panel { + self.rx_panel + .show(ui, chart_area, self.settings.auto_scroll_log); + ui.separator(); + } self.connection_panel .show(ui, &mut self.serial_manager, &mut self.serial_events_rx); diff --git a/src/generalsettings.rs b/src/generalsettings.rs index 715a9c3..a7a4b5d 100644 --- a/src/generalsettings.rs +++ b/src/generalsettings.rs @@ -12,6 +12,8 @@ pub struct AppSettings { pub window_width: f32, pub window_height: f32, pub byte_mode: bool, + pub show_chart_panel: bool, + pub show_text_panel: bool, } impl Default for AppSettings { @@ -27,6 +29,8 @@ impl Default for AppSettings { window_width: 1050.0, window_height: 500.0, byte_mode: false, + show_chart_panel: false, + show_text_panel: true, } } } diff --git a/src/gui/chart_panel.rs b/src/gui/chart_panel.rs new file mode 100644 index 0000000..dbb42a4 --- /dev/null +++ b/src/gui/chart_panel.rs @@ -0,0 +1,236 @@ +use egui::{containers::Frame, emath, epaint, epaint::PathStroke, pos2, Color32, Pos2, Rect, Vec2}; +use std::collections::VecDeque; + +#[derive(Default)] +pub struct ChartPanel { + pub content: VecDeque, + #[allow(dead_code)] + should_scroll_to_bottom: bool, + samples: VecDeque, + stream_buffer: String, // Buffer to accumulate partial data +} + +impl ChartPanel { + pub fn new(_max_length: usize) -> Self { + Self { + content: VecDeque::new(), + should_scroll_to_bottom: false, + samples: VecDeque::new(), + stream_buffer: String::new(), + } + } + + pub fn process_rx(&mut self, message: Vec) { + // Convert bytes to string and add to buffer + let message_str = String::from_utf8_lossy(&message); + self.stream_buffer.push_str(&message_str); + + // Process complete values (those followed by commas) + while let Some(comma_pos) = self.stream_buffer.find(',') { + // Extract the complete value (everything up to the comma) + let complete_value = self.stream_buffer[..comma_pos].trim().to_string(); + + // Remove the processed part from buffer (including the comma) + self.stream_buffer = self.stream_buffer[comma_pos + 1..].to_string(); + + // Try to parse the complete value + if !complete_value.is_empty() { + if let Ok(sample) = complete_value.parse::() { + self.samples.push_back(sample); + } + } + } + + // Prevent buffer from growing indefinitely + // Keep only the last incomplete value + if self.stream_buffer.len() > 50 { + // If buffer gets too long without a comma, it's probably garbage + // Keep only the last 20 characters in case there's a valid number at the end + let keep_from = self.stream_buffer.len().saturating_sub(20); + self.stream_buffer = self.stream_buffer[keep_from..].to_string(); + } + + // Limit the samples size to prevent memory growth + while self.samples.len() > 1000 { + self.samples.pop_front(); + } + } + + pub fn show(&mut self, ui: &mut egui::Ui, available_size: Vec2, _autoscroll: bool) { + // Reserve space for axes labels + let margin_left = 60.0; + let margin_bottom = 30.0; + let plot_size = Vec2::new( + available_size.x - margin_left, + (available_size.y * 0.85) - margin_bottom, + ); + + ui.horizontal(|ui| { + // Y-axis labels area + ui.allocate_ui_with_layout( + Vec2::new(margin_left - 10.0, plot_size.y), + egui::Layout::top_down(egui::Align::RIGHT), + |ui| { + if !self.samples.is_empty() { + let min_y = self.samples.iter().cloned().fold(f32::INFINITY, f32::min); + let max_y = self + .samples + .iter() + .cloned() + .fold(f32::NEG_INFINITY, f32::max); + let y_range = max_y - min_y; + let padding = if y_range > 0.0 { y_range * 0.1 } else { 1.0 }; + let padded_min = min_y - padding; + let padded_max = max_y + padding; + + // Draw Y-axis labels + for i in 0..=5 { + let y_val = + padded_min + (padded_max - padded_min) * (1.0 - i as f32 / 5.0); + let y_pos = (plot_size.y / 5.0) * i as f32; + + ui.allocate_new_ui( + egui::UiBuilder::new().max_rect(Rect::from_min_size( + ui.min_rect().min + egui::vec2(0.0, y_pos - 8.0), + Vec2::new(margin_left - 15.0, 16.0), + )), + |ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label(format!("{y_val:.1}")); + }, + ); + }, + ); + } + } + }, + ); + + // Plot area + Frame::canvas(ui.style()).show(ui, |ui| { + ui.ctx().request_repaint(); + + let (_id, rect) = ui.allocate_space(plot_size); + + // Auto-scale based on actual data + let (min_val, max_val, x_range) = if self.samples.is_empty() { + (-1.0, 1.0, 0.0..=100.0) + } else { + let min_y = self.samples.iter().cloned().fold(f32::INFINITY, f32::min); + let max_y = self + .samples + .iter() + .cloned() + .fold(f32::NEG_INFINITY, f32::max); + + let y_range = max_y - min_y; + let padding = if y_range > 0.0 { y_range * 0.1 } else { 1.0 }; + let padded_min = min_y - padding; + let padded_max = max_y + padding; + + let x_max = (self.samples.len() as f32).max(10.0); + + (padded_min, padded_max, 0.0..=x_max) + }; + + let to_screen = emath::RectTransform::from_to( + Rect::from_x_y_ranges(x_range, max_val..=min_val), // Change this line + rect, + ); + + let mut shapes = vec![]; + + // Draw grid lines + if !self.samples.is_empty() { + // Horizontal grid lines + for i in 0..=5 { + let y = min_val + (max_val - min_val) * (i as f32 / 5.0); + let start = to_screen * pos2(0.0, y); + let end = to_screen * pos2(self.samples.len() as f32, y); + shapes.push(epaint::Shape::line_segment( + [start, end], + (0.5, Color32::GRAY.gamma_multiply(0.2)), + )); + } + + // Vertical grid lines + let num_v_lines = 8; + for i in 0..=num_v_lines { + let x = (self.samples.len() as f32) * (i as f32 / num_v_lines as f32); + let start = to_screen * pos2(x, min_val); + let end = to_screen * pos2(x, max_val); + shapes.push(epaint::Shape::line_segment( + [start, end], + (0.5, Color32::GRAY.gamma_multiply(0.2)), + )); + } + } + + // Draw data line + if !self.samples.is_empty() { + let points: Vec = self + .samples + .iter() + .enumerate() + .map(|(i, &value)| to_screen * pos2(i as f32, value)) + .collect(); + + shapes.push(epaint::Shape::line( + points, + PathStroke::new(2.0, Color32::from_rgb(0, 150, 255)), + )); + } + + ui.painter().extend(shapes); + }); + }); + + // X-axis labels + if !self.samples.is_empty() { + ui.horizontal(|ui| { + ui.add_space(margin_left); + + let num_x_labels = 6; + let label_width = plot_size.x / num_x_labels as f32; + + for i in 0..=num_x_labels { + let x_val = (self.samples.len() as f32) * (i as f32 / num_x_labels as f32); + + ui.allocate_ui_with_layout( + Vec2::new(label_width, 20.0), + egui::Layout::centered_and_justified(egui::Direction::TopDown), + |ui| { + ui.label(format!("{x_val:.0}")); + }, + ); + } + }); + } + } +} + +impl serde::Serialize for ChartPanel { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.content.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for ChartPanel { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let content = VecDeque::deserialize(deserializer)?; + Ok(Self { + content, + should_scroll_to_bottom: false, + samples: VecDeque::new(), + stream_buffer: String::new(), + }) + } +} diff --git a/src/gui/menu_bar.rs b/src/gui/menu_bar.rs index 6c8b6e5..fd24657 100644 --- a/src/gui/menu_bar.rs +++ b/src/gui/menu_bar.rs @@ -40,6 +40,16 @@ impl MenuBar { clear_callback(); } ui.checkbox(&mut settings.byte_mode, "Byte mode"); + + // Ensure at least one panel is always enabled + ui.add_enabled( + settings.show_text_panel, + egui::Checkbox::new(&mut settings.show_chart_panel, "Show chart panel"), + ); + ui.add_enabled( + settings.show_chart_panel, + egui::Checkbox::new(&mut settings.show_text_panel, "Show text panel"), + ); }); }); }); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 9ee190b..c198b26 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1,3 +1,4 @@ +pub mod chart_panel; pub mod connection_panel; pub mod file_log_panel; pub mod menu_bar; @@ -6,6 +7,7 @@ pub mod send_panel; pub mod settings_panel; // Re-export para facilitar el uso +pub use chart_panel::ChartPanel; pub use connection_panel::ConnectionPanel; pub use file_log_panel::FileLogPanel; pub use menu_bar::MenuBar; diff --git a/src/gui/settings_panel.rs b/src/gui/settings_panel.rs index d421148..3ee6860 100644 --- a/src/gui/settings_panel.rs +++ b/src/gui/settings_panel.rs @@ -40,6 +40,8 @@ impl SettingsPanel { }); ui.checkbox(&mut settings.byte_mode, "Byte mode"); + ui.checkbox(&mut settings.show_chart_panel, "Show chart panel"); + ui.checkbox(&mut settings.show_text_panel, "Show text panel"); }); } }