From f067b7e3a6c0454cf7374ae70e16e02b2f96c4a0 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Tue, 30 Dec 2025 16:20:01 -0500 Subject: [PATCH 01/13] Histogram Tool Support (#27) * Initial build of the histogram tool Signed-off-by: Cole Gentry * More improvements to the histogram to increase features availble Signed-off-by: Cole Gentry --------- Signed-off-by: Cole Gentry --- .claude/settings.json | 5 +- src/app.rs | 36 +- src/state.rs | 103 ++++ src/ui/export.rs | 467 +++++++++++++++++ src/ui/histogram.rs | 1019 +++++++++++++++++++++++++++++++++++++ src/ui/menu.rs | 41 +- src/ui/mod.rs | 1 + src/ui/sidebar.rs | 8 + src/ui/tool_switcher.rs | 6 +- tests/core/state_tests.rs | 321 +++++++++++- 10 files changed, 1980 insertions(+), 27 deletions(-) create mode 100644 src/ui/histogram.rs diff --git a/.claude/settings.json b/.claude/settings.json index 0d6ce43..d3077ae 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,10 @@ "Bash(wc:*)", "Bash(grep:*)", "Bash(ls:*)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "Bash(git config:*)", + "Bash(/Users/colegentry/Development/UltraLog/.githooks/pre-commit)", + "Bash(./scripts/bump-version.sh:*)" ] } } \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 5823d37..e42c93c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1351,20 +1351,28 @@ impl eframe::App for UltraLogApp { .show(ctx, |ui| { self.render_channel_selection(ui); }); + } - // Bottom panel for timeline scrubber (only in Log Viewer mode) - if self.get_time_range().is_some() && !self.get_selected_channels().is_empty() { - egui::TopBottomPanel::bottom("timeline_panel") - .resizable(false) - .min_height(60.0) - .show(ctx, |ui| { - ui.add_space(5.0); - self.render_record_indicator(ui); - ui.separator(); - self.render_timeline_scrubber(ui); - ui.add_space(5.0); - }); + // Bottom panel for timeline scrubber (Log Viewer and Histogram modes) + let show_timeline = match self.active_tool { + ActiveTool::LogViewer => { + self.get_time_range().is_some() && !self.get_selected_channels().is_empty() } + ActiveTool::Histogram => self.get_time_range().is_some(), + ActiveTool::ScatterPlot => false, + }; + + if show_timeline { + egui::TopBottomPanel::bottom("timeline_panel") + .resizable(false) + .min_height(60.0) + .show(ctx, |ui| { + ui.add_space(5.0); + self.render_record_indicator(ui); + ui.separator(); + self.render_timeline_scrubber(ui); + ui.add_space(5.0); + }); } // Main content area - render based on active tool @@ -1388,6 +1396,10 @@ impl eframe::App for UltraLogApp { ui.add_space(10.0); self.render_scatter_plot_view(ui); } + ActiveTool::Histogram => { + ui.add_space(10.0); + self.render_histogram_view(ui); + } } }); } diff --git a/src/state.rs b/src/state.rs index cfb6a5e..9ecdd46 100644 --- a/src/state.rs +++ b/src/state.rs @@ -180,6 +180,8 @@ pub enum ActiveTool { LogViewer, /// Scatter plot view for comparing two variables with color coding ScatterPlot, + /// Histogram view for 2D distribution analysis + Histogram, } impl ActiveTool { @@ -188,6 +190,7 @@ impl ActiveTool { match self { ActiveTool::LogViewer => "Log Viewer", ActiveTool::ScatterPlot => "Scatter Plots", + ActiveTool::Histogram => "Histogram", } } } @@ -227,6 +230,103 @@ pub struct ScatterPlotState { pub right: ScatterPlotConfig, } +// ============================================================================ +// Histogram Types +// ============================================================================ + +/// Display mode for histogram cell values +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum HistogramMode { + /// Show average Z-channel value in cells + #[default] + AverageZ, + /// Show hit count (number of data points) in cells + HitCount, +} + +/// Grid size options for histogram +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum HistogramGridSize { + /// 16x16 grid + Size16, + /// 32x32 grid + #[default] + Size32, + /// 64x64 grid + Size64, +} + +impl HistogramGridSize { + /// Get the numeric size value + pub fn size(&self) -> usize { + match self { + HistogramGridSize::Size16 => 16, + HistogramGridSize::Size32 => 32, + HistogramGridSize::Size64 => 64, + } + } + + /// Get display name + pub fn name(&self) -> &'static str { + match self { + HistogramGridSize::Size16 => "16x16", + HistogramGridSize::Size32 => "32x32", + HistogramGridSize::Size64 => "64x64", + } + } +} + +/// Statistics for a selected histogram cell +#[derive(Clone, Default)] +pub struct SelectedHistogramCell { + /// X bin index + pub x_bin: usize, + /// Y bin index + pub y_bin: usize, + /// X axis value range (min, max) for this cell + pub x_range: (f64, f64), + /// Y axis value range (min, max) for this cell + pub y_range: (f64, f64), + /// Number of data points in cell + pub hit_count: u32, + /// Sum of weights (for weighted averaging) + pub cell_weight: f64, + /// Variance of Z values + pub variance: f64, + /// Standard deviation of Z values + pub std_dev: f64, + /// Minimum Z value in cell + pub minimum: f64, + /// Mean Z value in cell + pub mean: f64, + /// Maximum Z value in cell + pub maximum: f64, +} + +/// Configuration for the histogram view +#[derive(Clone, Default)] +pub struct HistogramConfig { + /// Channel index for X axis + pub x_channel: Option, + /// Channel index for Y axis + pub y_channel: Option, + /// Channel index for Z axis (value to average) + pub z_channel: Option, + /// Display mode (average Z vs hit count) + pub mode: HistogramMode, + /// Grid size + pub grid_size: HistogramGridSize, + /// Currently selected cell (for statistics display) + pub selected_cell: Option, +} + +/// State for the histogram view +#[derive(Clone, Default)] +pub struct HistogramState { + /// Histogram configuration + pub config: HistogramConfig, +} + // ============================================================================ // Tab Types // ============================================================================ @@ -252,6 +352,8 @@ pub struct Tab { pub time_range: Option<(f64, f64)>, /// Scatter plot state for this tab (dual heatmaps) pub scatter_plot_state: ScatterPlotState, + /// Histogram state for this tab + pub histogram_state: HistogramState, /// Request to jump the view to a specific time (used for min/max jump buttons) pub jump_to_time: Option, } @@ -274,6 +376,7 @@ impl Tab { chart_interacted: false, time_range: None, scatter_plot_state, + histogram_state: HistogramState::default(), jump_to_time: None, } } diff --git a/src/ui/export.rs b/src/ui/export.rs index a25df9c..9e4e7fd 100644 --- a/src/ui/export.rs +++ b/src/ui/export.rs @@ -1,5 +1,6 @@ //! Chart export functionality (PNG, PDF). +use printpdf::path::{PaintMode, WindingOrder}; use printpdf::*; use std::fs::File; use std::io::BufWriter; @@ -10,6 +11,7 @@ use ::image::{Rgba, RgbaImage}; use crate::analytics; use crate::app::UltraLogApp; use crate::normalize::normalize_channel_name_with_custom; +use crate::state::HistogramMode; impl UltraLogApp { /// Export the current chart view as PNG @@ -352,6 +354,471 @@ impl UltraLogApp { Ok(()) } + + /// Export the current histogram view as PDF + pub fn export_histogram_pdf(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name("ultralog_histogram.pdf") + .save_file() + else { + return; + }; + + match self.render_histogram_to_pdf(&path) { + Ok(_) => { + analytics::track_export("histogram_pdf"); + self.show_toast_success("Histogram exported as PDF"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + + /// Render histogram to PDF file + fn render_histogram_to_pdf( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + // Get tab and file data + let tab_idx = self.active_tab.ok_or("No active tab")?; + let config = &self.tabs[tab_idx].histogram_state.config; + let file_idx = self.tabs[tab_idx].file_index; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let mode = config.mode; + let grid_size = config.grid_size.size(); + + let x_idx = config.x_channel.ok_or("X axis not selected")?; + let y_idx = config.y_channel.ok_or("Y axis not selected")?; + let z_idx = if mode == HistogramMode::AverageZ { + config + .z_channel + .ok_or("Z axis not selected for Average mode")? + } else { + 0 // unused + }; + + // Get channel data + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + let z_data = if mode == HistogramMode::AverageZ { + Some(file.log.get_channel_data(z_idx)) + } else { + None + }; + + if x_data.is_empty() || y_data.is_empty() { + return Err("No data available".into()); + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build histogram grid + let mut hit_counts = vec![vec![0u32; grid_size]; grid_size]; + let mut z_sums = vec![vec![0.0f64; grid_size]; grid_size]; + + for i in 0..x_data.len() { + let x_bin = (((x_data[i] - x_min) / x_range) * (grid_size - 1) as f64).round() as usize; + let y_bin = (((y_data[i] - y_min) / y_range) * (grid_size - 1) as f64).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + hit_counts[y_bin][x_bin] += 1; + if let Some(ref z) = z_data { + z_sums[y_bin][x_bin] += z[i]; + } + } + + // Calculate cell values and find min/max for color scaling + let mut cell_values = vec![vec![None::; grid_size]; grid_size]; + let mut min_value: f64 = f64::MAX; + let mut max_value: f64 = f64::MIN; + + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let hits = hit_counts[y_bin][x_bin]; + if hits > 0 { + let value = match mode { + HistogramMode::HitCount => hits as f64, + HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, + }; + cell_values[y_bin][x_bin] = Some(value); + min_value = min_value.min(value); + max_value = max_value.max(value); + } + } + } + + let value_range = if (max_value - min_value).abs() < f64::EPSILON { + 1.0 + } else { + max_value - min_value + }; + + // Get channel names + let x_name = file.log.channels[x_idx].name(); + let y_name = file.log.channels[y_idx].name(); + let z_name = if mode == HistogramMode::AverageZ { + file.log.channels[z_idx].name() + } else { + "Hit Count".to_string() + }; + + // Create PDF document (A4 landscape) + let (doc, page1, layer1) = PdfDocument::new( + "UltraLog Histogram Export", + Mm(297.0), + Mm(210.0), + "Histogram", + ); + + let current_layer = doc.get_page(page1).get_layer(layer1); + + // Chart dimensions in mm (A4 landscape with margins) + let margin: f64 = 20.0; + let axis_margin: f64 = 25.0; + let chart_left: f64 = margin + axis_margin; + let chart_right: f64 = 250.0; // Leave room for legend + let chart_bottom: f64 = margin + axis_margin; + let chart_top: f64 = 210.0 - margin - 30.0; + + let chart_width: f64 = chart_right - chart_left; + let chart_height: f64 = chart_top - chart_bottom; + + let cell_width = chart_width / grid_size as f64; + let cell_height = chart_height / grid_size as f64; + + // Draw title + let font = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + current_layer.use_text( + "UltraLog Histogram Export", + 16.0, + Mm(margin as f32), + Mm(200.0), + &font, + ); + + // Draw subtitle + let font_regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; + let subtitle = format!( + "{} | Grid: {}x{} | Mode: {}", + file.name, + grid_size, + grid_size, + if mode == HistogramMode::HitCount { + "Hit Count" + } else { + "Average Z" + } + ); + current_layer.use_text(&subtitle, 10.0, Mm(margin as f32), Mm(192.0), &font_regular); + + // Draw axis labels + let axis_subtitle = format!("X: {} | Y: {} | Z: {}", x_name, y_name, z_name); + current_layer.use_text( + &axis_subtitle, + 9.0, + Mm(margin as f32), + Mm(186.0), + &font_regular, + ); + + // Draw histogram cells + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let cell_x = chart_left + x_bin as f64 * cell_width; + let cell_y = chart_bottom + y_bin as f64 * cell_height; + + if let Some(value) = cell_values[y_bin][x_bin] { + // Calculate color + let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { + (value.ln() / max_value.ln()).clamp(0.0, 1.0) + } else { + ((value - min_value) / value_range).clamp(0.0, 1.0) + }; + let color = Self::get_pdf_heat_color(normalized); + + current_layer.set_fill_color(color); + + // Draw filled rectangle + let rect = printpdf::Polygon { + rings: vec![vec![ + (Point::new(Mm(cell_x as f32), Mm(cell_y as f32)), false), + ( + Point::new(Mm((cell_x + cell_width) as f32), Mm(cell_y as f32)), + false, + ), + ( + Point::new( + Mm((cell_x + cell_width) as f32), + Mm((cell_y + cell_height) as f32), + ), + false, + ), + ( + Point::new(Mm(cell_x as f32), Mm((cell_y + cell_height) as f32)), + false, + ), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + current_layer.add_polygon(rect); + + // Draw cell value text (only for smaller grids) + if grid_size <= 32 { + let text = if mode == HistogramMode::HitCount { + format!("{}", hit_counts[y_bin][x_bin]) + } else { + format!("{:.1}", value) + }; + + // Calculate text color based on brightness + let brightness = normalized; + let text_color = if brightness > 0.5 { + Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)) // Black + } else { + Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)) // White + }; + + current_layer.set_fill_color(text_color); + let font_size = if grid_size <= 16 { 6.0 } else { 4.0 }; + current_layer.use_text( + &text, + font_size, + Mm((cell_x + cell_width / 2.0 - 2.0) as f32), + Mm((cell_y + cell_height / 2.0 - 1.0) as f32), + &font_regular, + ); + } + } + } + } + + // Draw grid lines + let grid_color = Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)); + current_layer.set_outline_color(grid_color); + current_layer.set_outline_thickness(0.25); + + for i in 0..=grid_size { + let x = chart_left + i as f64 * cell_width; + let y = chart_bottom + i as f64 * cell_height; + + // Vertical line + let vline = Line { + points: vec![ + (Point::new(Mm(x as f32), Mm(chart_bottom as f32)), false), + (Point::new(Mm(x as f32), Mm(chart_top as f32)), false), + ], + is_closed: false, + }; + current_layer.add_line(vline); + + // Horizontal line + let hline = Line { + points: vec![ + (Point::new(Mm(chart_left as f32), Mm(y as f32)), false), + (Point::new(Mm(chart_right as f32), Mm(y as f32)), false), + ], + is_closed: false, + }; + current_layer.add_line(hline); + } + + // Draw axis value labels + let label_color = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); + current_layer.set_fill_color(label_color); + + // Y axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = y_min + t * y_range; + let y_pos = chart_bottom + t * chart_height; + current_layer.use_text( + format!("{:.0}", value), + 7.0, + Mm((chart_left - 12.0) as f32), + Mm((y_pos - 1.0) as f32), + &font_regular, + ); + } + + // X axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = x_min + t * x_range; + let x_pos = chart_left + t * chart_width; + current_layer.use_text( + format!("{:.0}", value), + 7.0, + Mm((x_pos - 4.0) as f32), + Mm((chart_bottom - 8.0) as f32), + &font_regular, + ); + } + + // Draw legend (color scale) + let legend_left: f64 = 260.0; + let legend_width: f64 = 15.0; + let legend_bottom: f64 = chart_bottom; + let legend_height: f64 = chart_height; + + // Draw legend title + current_layer.set_fill_color(Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None))); + let legend_title = if mode == HistogramMode::HitCount { + "Hits" + } else { + "Value" + }; + current_layer.use_text( + legend_title, + 9.0, + Mm(legend_left as f32), + Mm((legend_bottom + legend_height + 5.0) as f32), + &font, + ); + + // Draw color gradient bar + let gradient_steps = 30; + let step_height = legend_height / gradient_steps as f64; + + for i in 0..gradient_steps { + let t = i as f64 / gradient_steps as f64; + let color = Self::get_pdf_heat_color(t); + current_layer.set_fill_color(color); + + let y = legend_bottom + i as f64 * step_height; + let rect = printpdf::Polygon { + rings: vec![vec![ + (Point::new(Mm(legend_left as f32), Mm(y as f32)), false), + ( + Point::new(Mm((legend_left + legend_width) as f32), Mm(y as f32)), + false, + ), + ( + Point::new( + Mm((legend_left + legend_width) as f32), + Mm((y + step_height + 0.5) as f32), + ), + false, + ), + ( + Point::new(Mm(legend_left as f32), Mm((y + step_height + 0.5) as f32)), + false, + ), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + current_layer.add_polygon(rect); + } + + // Draw legend min/max labels + current_layer.set_fill_color(Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None))); + let min_label = if mode == HistogramMode::HitCount { + "0".to_string() + } else { + format!("{:.1}", min_value) + }; + let max_label = if mode == HistogramMode::HitCount { + format!("{:.0}", max_value) + } else { + format!("{:.1}", max_value) + }; + + current_layer.use_text( + &min_label, + 7.0, + Mm((legend_left + legend_width + 3.0) as f32), + Mm(legend_bottom as f32), + &font_regular, + ); + current_layer.use_text( + &max_label, + 7.0, + Mm((legend_left + legend_width + 3.0) as f32), + Mm((legend_bottom + legend_height - 3.0) as f32), + &font_regular, + ); + + // Draw statistics + let stats_y = legend_bottom - 15.0; + current_layer.use_text( + format!("Total Points: {}", x_data.len()), + 8.0, + Mm(legend_left as f32), + Mm(stats_y as f32), + &font_regular, + ); + + // Save PDF + let file = File::create(path)?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer)?; + + Ok(()) + } + + /// Get a PDF color from the heat map gradient based on normalized value (0-1) + fn get_pdf_heat_color(normalized: f64) -> Color { + const HEAT_COLORS: &[[u8; 3]] = &[ + [0, 0, 80], // Dark blue (0.0) + [0, 0, 180], // Blue + [0, 100, 255], // Light blue + [0, 200, 255], // Cyan + [0, 255, 200], // Cyan-green + [0, 255, 100], // Green + [100, 255, 0], // Yellow-green + [200, 255, 0], // Yellow + [255, 200, 0], // Orange + [255, 100, 0], // Red-orange + [255, 0, 0], // Red (1.0) + ]; + + let t = normalized.clamp(0.0, 1.0); + let scaled = t * (HEAT_COLORS.len() - 1) as f64; + let idx = scaled.floor() as usize; + let frac = scaled - idx as f64; + + if idx >= HEAT_COLORS.len() - 1 { + let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; + return Color::Rgb(Rgb::new( + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + None, + )); + } + + let c1 = HEAT_COLORS[idx]; + let c2 = HEAT_COLORS[idx + 1]; + + let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) / 255.0; + let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) / 255.0; + let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) / 255.0; + + Color::Rgb(Rgb::new(r as f32, g as f32, b as f32, None)) + } } /// Draw a line between two points using Bresenham's algorithm diff --git a/src/ui/histogram.rs b/src/ui/histogram.rs new file mode 100644 index 0000000..b7a7a6b --- /dev/null +++ b/src/ui/histogram.rs @@ -0,0 +1,1019 @@ +//! Histogram / 2D heatmap view for analyzing channel distributions. +//! +//! This module provides a histogram view where users can visualize +//! relationships between channels as a 2D grid with configurable +//! cell coloring based on average Z-value or hit count. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::normalize::sort_channels_by_priority; +use crate::state::{HistogramGridSize, HistogramMode, SelectedHistogramCell}; + +/// Heat map color gradient from blue (low) to red (high) +const HEAT_COLORS: &[[u8; 3]] = &[ + [0, 0, 80], // Dark blue (0.0) + [0, 0, 180], // Blue + [0, 100, 255], // Light blue + [0, 200, 255], // Cyan + [0, 255, 200], // Cyan-green + [0, 255, 100], // Green + [100, 255, 0], // Yellow-green + [200, 255, 0], // Yellow + [255, 200, 0], // Orange + [255, 100, 0], // Red-orange + [255, 0, 0], // Red (1.0) +]; + +/// Margin for axis labels and titles +const AXIS_LABEL_MARGIN_LEFT: f32 = 75.0; +const AXIS_LABEL_MARGIN_BOTTOM: f32 = 45.0; + +/// Height reserved for legend at bottom +const LEGEND_HEIGHT: f32 = 55.0; + +/// Current position indicator color (cyan, matches chart cursor) +const CURSOR_COLOR: egui::Color32 = egui::Color32::from_rgb(0, 255, 255); + +/// Cell highlight color for current position +const CELL_HIGHLIGHT_COLOR: egui::Color32 = egui::Color32::WHITE; + +/// Selected cell highlight color +const SELECTED_CELL_COLOR: egui::Color32 = egui::Color32::from_rgb(255, 165, 0); // Orange + +/// Calculate relative luminance for WCAG contrast ratio +/// Uses the sRGB colorspace formula from WCAG 2.1 +fn calculate_luminance(color: egui::Color32) -> f64 { + let r = linearize_channel(color.r()); + let g = linearize_channel(color.g()); + let b = linearize_channel(color.b()); + 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/// Linearize an sRGB channel value (0-255) to linear RGB +fn linearize_channel(value: u8) -> f64 { + let v = value as f64 / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } +} + +/// Calculate WCAG contrast ratio between two colors +fn contrast_ratio(color1: egui::Color32, color2: egui::Color32) -> f64 { + let l1 = calculate_luminance(color1); + let l2 = calculate_luminance(color2); + let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) }; + (lighter + 0.05) / (darker + 0.05) +} + +/// Get the best text color (black or white) for AAA compliance on given background +/// Returns the color that provides the highest contrast ratio +fn get_aaa_text_color(background: egui::Color32) -> egui::Color32 { + let white_contrast = contrast_ratio(egui::Color32::WHITE, background); + let black_contrast = contrast_ratio(egui::Color32::BLACK, background); + + // Choose whichever provides better contrast + // AAA requires 7:1 for normal text, but we pick the better option regardless + if white_contrast >= black_contrast { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + } +} + +impl UltraLogApp { + /// Main entry point: render the histogram view + pub fn render_histogram_view(&mut self, ui: &mut egui::Ui) { + if self.active_tab.is_none() || self.files.is_empty() { + ui.centered_and_justified(|ui| { + ui.label( + egui::RichText::new("Load a log file to use histogram") + .size(20.0) + .color(egui::Color32::GRAY), + ); + }); + return; + } + + // Render tab bar + self.render_tab_bar(ui); + ui.add_space(10.0); + + // Render axis selectors and mode toggle + self.render_histogram_controls(ui); + ui.add_space(8.0); + + // Render the histogram grid + self.render_histogram_grid(ui); + } + + /// Render the control panel with axis selectors, mode toggle, and grid size + fn render_histogram_controls(&mut self, ui: &mut egui::Ui) { + let Some(tab_idx) = self.active_tab else { + return; + }; + let file_idx = self.tabs[tab_idx].file_index; + + if file_idx >= self.files.len() { + return; + } + + let file = &self.files[file_idx]; + + // Sort channels for dropdown + let sorted_channels = sort_channels_by_priority( + file.log.channels.len(), + |idx| file.log.channels[idx].name(), + self.field_normalization, + Some(&self.custom_normalizations), + ); + + // Get current selections + let config = &self.tabs[tab_idx].histogram_state.config; + let current_x = config.x_channel; + let current_y = config.y_channel; + let current_z = config.z_channel; + let current_mode = config.mode; + let current_grid_size = config.grid_size; + + // Build channel name lookup + let channel_names: std::collections::HashMap = sorted_channels + .iter() + .map(|(idx, name, _)| (*idx, name.clone())) + .collect(); + + // Track selections for deferred updates + let mut new_x: Option = None; + let mut new_y: Option = None; + let mut new_z: Option = None; + let mut new_mode: Option = None; + let mut new_grid_size: Option = None; + + ui.horizontal(|ui| { + // X Axis selector + ui.label(egui::RichText::new("X Axis:").size(15.0)); + egui::ComboBox::from_id_salt("histogram_x") + .selected_text( + egui::RichText::new( + current_x + .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) + .unwrap_or("Select..."), + ) + .size(14.0), + ) + .width(160.0) + .show_ui(ui, |ui| { + for (idx, name, _) in &sorted_channels { + if ui + .selectable_label( + current_x == Some(*idx), + egui::RichText::new(name).size(14.0), + ) + .clicked() + { + new_x = Some(*idx); + } + } + }); + + ui.add_space(16.0); + + // Y Axis selector + ui.label(egui::RichText::new("Y Axis:").size(15.0)); + egui::ComboBox::from_id_salt("histogram_y") + .selected_text( + egui::RichText::new( + current_y + .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) + .unwrap_or("Select..."), + ) + .size(14.0), + ) + .width(160.0) + .show_ui(ui, |ui| { + for (idx, name, _) in &sorted_channels { + if ui + .selectable_label( + current_y == Some(*idx), + egui::RichText::new(name).size(14.0), + ) + .clicked() + { + new_y = Some(*idx); + } + } + }); + + ui.add_space(16.0); + + // Z Axis selector (only enabled in AverageZ mode) + let z_enabled = current_mode == HistogramMode::AverageZ; + ui.add_enabled_ui(z_enabled, |ui| { + ui.label(egui::RichText::new("Z Axis:").size(15.0)); + egui::ComboBox::from_id_salt("histogram_z") + .selected_text( + egui::RichText::new( + current_z + .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) + .unwrap_or("Select..."), + ) + .size(14.0), + ) + .width(160.0) + .show_ui(ui, |ui| { + for (idx, name, _) in &sorted_channels { + if ui + .selectable_label( + current_z == Some(*idx), + egui::RichText::new(name).size(14.0), + ) + .clicked() + { + new_z = Some(*idx); + } + } + }); + }); + + ui.add_space(20.0); + + // Grid size selector + ui.label(egui::RichText::new("Grid:").size(15.0)); + egui::ComboBox::from_id_salt("histogram_grid_size") + .selected_text(egui::RichText::new(current_grid_size.name()).size(14.0)) + .width(80.0) + .show_ui(ui, |ui| { + let sizes = [ + HistogramGridSize::Size16, + HistogramGridSize::Size32, + HistogramGridSize::Size64, + ]; + for size in sizes { + if ui + .selectable_label( + current_grid_size == size, + egui::RichText::new(size.name()).size(14.0), + ) + .clicked() + { + new_grid_size = Some(size); + } + } + }); + + ui.add_space(20.0); + + // Mode toggle + ui.label(egui::RichText::new("Mode:").size(15.0)); + if ui + .selectable_label( + current_mode == HistogramMode::AverageZ, + egui::RichText::new("Average Z").size(14.0), + ) + .clicked() + { + new_mode = Some(HistogramMode::AverageZ); + } + if ui + .selectable_label( + current_mode == HistogramMode::HitCount, + egui::RichText::new("Hit Count").size(14.0), + ) + .clicked() + { + new_mode = Some(HistogramMode::HitCount); + } + }); + + // Apply deferred updates + let config = &mut self.tabs[tab_idx].histogram_state.config; + if let Some(x) = new_x { + config.x_channel = Some(x); + config.selected_cell = None; // Clear selection on axis change + } + if let Some(y) = new_y { + config.y_channel = Some(y); + config.selected_cell = None; + } + if let Some(z) = new_z { + config.z_channel = Some(z); + config.selected_cell = None; + } + if let Some(mode) = new_mode { + config.mode = mode; + config.selected_cell = None; + } + if let Some(size) = new_grid_size { + config.grid_size = size; + config.selected_cell = None; + } + } + + /// Render the histogram grid with current position indicator + fn render_histogram_grid(&mut self, ui: &mut egui::Ui) { + let Some(tab_idx) = self.active_tab else { + return; + }; + + let config = &self.tabs[tab_idx].histogram_state.config; + let file_idx = self.tabs[tab_idx].file_index; + let mode = config.mode; + let grid_size = config.grid_size.size(); + + // Check valid axis selections + let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { + (Some(x), Some(y)) => (x, y), + _ => { + let available = ui.available_size(); + let (rect, _) = ui.allocate_exact_size(available, egui::Sense::hover()); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Select X and Y axes", + egui::FontId::proportional(16.0), + egui::Color32::GRAY, + ); + return; + } + }; + + // Check Z axis for AverageZ mode + let z_idx = if mode == HistogramMode::AverageZ { + match config.z_channel { + Some(z) => Some(z), + None => { + let available = ui.available_size(); + let (rect, _) = ui.allocate_exact_size(available, egui::Sense::hover()); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Select Z axis for Average mode", + egui::FontId::proportional(16.0), + egui::Color32::GRAY, + ); + return; + } + } + } else { + None + }; + + if file_idx >= self.files.len() { + return; + } + + let file = &self.files[file_idx]; + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + let z_data = z_idx.map(|z| file.log.get_channel_data(z)); + + if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { + return; + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build histogram grid with full statistics + let mut hit_counts = vec![vec![0u32; grid_size]; grid_size]; + let mut z_sums = vec![vec![0.0f64; grid_size]; grid_size]; + let mut z_sum_sq = vec![vec![0.0f64; grid_size]; grid_size]; + let mut z_mins = vec![vec![f64::MAX; grid_size]; grid_size]; + let mut z_maxs = vec![vec![f64::MIN; grid_size]; grid_size]; + + for i in 0..x_data.len() { + let x_bin = (((x_data[i] - x_min) / x_range) * (grid_size - 1) as f64).round() as usize; + let y_bin = (((y_data[i] - y_min) / y_range) * (grid_size - 1) as f64).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + hit_counts[y_bin][x_bin] += 1; + if let Some(ref z) = z_data { + let z_val = z[i]; + z_sums[y_bin][x_bin] += z_val; + z_sum_sq[y_bin][x_bin] += z_val * z_val; + z_mins[y_bin][x_bin] = z_mins[y_bin][x_bin].min(z_val); + z_maxs[y_bin][x_bin] = z_maxs[y_bin][x_bin].max(z_val); + } + } + + // Calculate cell values and find min/max for color scaling + let mut cell_values = vec![vec![None::; grid_size]; grid_size]; + let mut min_value: f64 = f64::MAX; + let mut max_value: f64 = f64::MIN; + + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let hits = hit_counts[y_bin][x_bin]; + if hits > 0 { + let value = match mode { + HistogramMode::HitCount => hits as f64, + HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, + }; + cell_values[y_bin][x_bin] = Some(value); + min_value = min_value.min(value); + max_value = max_value.max(value); + } + } + } + + // Handle case where all values are the same + let value_range = if (max_value - min_value).abs() < f64::EPSILON { + 1.0 + } else { + max_value - min_value + }; + + // Allocate space for the grid + let available = ui.available_size(); + let chart_size = egui::vec2(available.x, (available.y - LEGEND_HEIGHT).max(200.0)); + let (full_rect, response) = ui.allocate_exact_size(chart_size, egui::Sense::click()); + + // Create inner plot rect with margins + let plot_rect = egui::Rect::from_min_max( + egui::pos2(full_rect.left() + AXIS_LABEL_MARGIN_LEFT, full_rect.top()), + egui::pos2( + full_rect.right(), + full_rect.bottom() - AXIS_LABEL_MARGIN_BOTTOM, + ), + ); + + let painter = ui.painter_at(full_rect); + painter.rect_filled(plot_rect, 0.0, egui::Color32::BLACK); + + let cell_width = plot_rect.width() / grid_size as f32; + let cell_height = plot_rect.height() / grid_size as f32; + + // Get selected cell for highlighting + let selected_cell = self.tabs[tab_idx] + .histogram_state + .config + .selected_cell + .clone(); + + // Draw grid cells with values + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let cell_x = plot_rect.left() + x_bin as f32 * cell_width; + let cell_y = plot_rect.bottom() - (y_bin + 1) as f32 * cell_height; + let cell_rect = egui::Rect::from_min_size( + egui::pos2(cell_x, cell_y), + egui::vec2(cell_width, cell_height), + ); + + if let Some(value) = cell_values[y_bin][x_bin] { + // Normalize to 0-1 for color scaling + let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { + (value.ln() / max_value.ln()).clamp(0.0, 1.0) + } else { + ((value - min_value) / value_range).clamp(0.0, 1.0) + }; + let color = Self::get_histogram_color(normalized); + + painter.rect_filled(cell_rect, 0.0, color); + + // Draw value text in center of cell (only if cell is large enough) + if cell_width > 25.0 && cell_height > 18.0 { + let text = if mode == HistogramMode::HitCount { + format!("{}", hit_counts[y_bin][x_bin]) + } else { + format!("{:.1}", value) + }; + + // Choose text color for AAA contrast compliance + let text_color = get_aaa_text_color(color); + + let font_size = if grid_size <= 16 { + 11.0 + } else if grid_size <= 32 { + 9.0 + } else { + 7.0 + }; + + painter.text( + cell_rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(font_size), + text_color, + ); + } + } + } + } + + // Draw grid lines + let grid_color = egui::Color32::from_rgb(60, 60, 60); + for i in 0..=grid_size { + let x = plot_rect.left() + i as f32 * cell_width; + let y = plot_rect.top() + i as f32 * cell_height; + painter.line_segment( + [ + egui::pos2(x, plot_rect.top()), + egui::pos2(x, plot_rect.bottom()), + ], + egui::Stroke::new(0.5, grid_color), + ); + painter.line_segment( + [ + egui::pos2(plot_rect.left(), y), + egui::pos2(plot_rect.right(), y), + ], + egui::Stroke::new(0.5, grid_color), + ); + } + + // Get channel names for axis labels + let x_channel_name = file.log.channels[x_idx].name(); + let y_channel_name = file.log.channels[y_idx].name(); + + // Draw axis labels + let text_color = egui::Color32::from_rgb(200, 200, 200); + let axis_title_color = egui::Color32::from_rgb(255, 255, 255); + + // Y axis value labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = y_min + t * y_range; + let y_pos = plot_rect.bottom() - t as f32 * plot_rect.height(); + painter.text( + egui::pos2(plot_rect.left() - 8.0, y_pos), + egui::Align2::RIGHT_CENTER, + format!("{:.1}", value), + egui::FontId::proportional(10.0), + text_color, + ); + } + + // Y axis title (rotated text simulation - draw vertically) + let y_title_x = full_rect.left() + 12.0; + let y_title_y = plot_rect.center().y; + painter.text( + egui::pos2(y_title_x, y_title_y), + egui::Align2::CENTER_CENTER, + &y_channel_name, + egui::FontId::proportional(13.0), + axis_title_color, + ); + + // X axis value labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = x_min + t * x_range; + let x_pos = plot_rect.left() + t as f32 * plot_rect.width(); + painter.text( + egui::pos2(x_pos, plot_rect.bottom() + 5.0), + egui::Align2::CENTER_TOP, + format!("{:.0}", value), + egui::FontId::proportional(10.0), + text_color, + ); + } + + // X axis title + let x_title_x = plot_rect.center().x; + let x_title_y = full_rect.bottom() - 8.0; + painter.text( + egui::pos2(x_title_x, x_title_y), + egui::Align2::CENTER_CENTER, + &x_channel_name, + egui::FontId::proportional(13.0), + axis_title_color, + ); + + // Draw selected cell highlight + if let Some(ref sel) = selected_cell { + if sel.x_bin < grid_size && sel.y_bin < grid_size { + let sel_x = plot_rect.left() + sel.x_bin as f32 * cell_width; + let sel_y = plot_rect.bottom() - (sel.y_bin + 1) as f32 * cell_height; + let sel_rect = egui::Rect::from_min_size( + egui::pos2(sel_x, sel_y), + egui::vec2(cell_width, cell_height), + ); + let stroke = egui::Stroke::new(3.0, SELECTED_CELL_COLOR); + painter.line_segment([sel_rect.left_top(), sel_rect.right_top()], stroke); + painter.line_segment([sel_rect.left_bottom(), sel_rect.right_bottom()], stroke); + painter.line_segment([sel_rect.left_top(), sel_rect.left_bottom()], stroke); + painter.line_segment([sel_rect.right_top(), sel_rect.right_bottom()], stroke); + } + } + + // Draw current position indicator (cursor time) + if let Some(cursor_record) = self.get_cursor_record() { + if cursor_record < x_data.len() { + let cursor_x = x_data[cursor_record]; + let cursor_y = y_data[cursor_record]; + + let rel_x = ((cursor_x - x_min) / x_range) as f32; + let rel_y = ((cursor_y - y_min) / y_range) as f32; + + if (0.0..=1.0).contains(&rel_x) && (0.0..=1.0).contains(&rel_y) { + let pos_x = plot_rect.left() + rel_x * plot_rect.width(); + let pos_y = plot_rect.bottom() - rel_y * plot_rect.height(); + + // Highlight the cell containing the cursor + let cell_x_bin = (rel_x * (grid_size - 1) as f32).round() as usize; + let cell_y_bin = (rel_y * (grid_size - 1) as f32).round() as usize; + let cell_x_bin = cell_x_bin.min(grid_size - 1); + let cell_y_bin = cell_y_bin.min(grid_size - 1); + + let highlight_x = plot_rect.left() + cell_x_bin as f32 * cell_width; + let highlight_y = plot_rect.bottom() - (cell_y_bin + 1) as f32 * cell_height; + + let highlight_rect = egui::Rect::from_min_size( + egui::pos2(highlight_x, highlight_y), + egui::vec2(cell_width, cell_height), + ); + let stroke = egui::Stroke::new(2.0, CELL_HIGHLIGHT_COLOR); + painter.line_segment( + [highlight_rect.left_top(), highlight_rect.right_top()], + stroke, + ); + painter.line_segment( + [highlight_rect.left_bottom(), highlight_rect.right_bottom()], + stroke, + ); + painter.line_segment( + [highlight_rect.left_top(), highlight_rect.left_bottom()], + stroke, + ); + painter.line_segment( + [highlight_rect.right_top(), highlight_rect.right_bottom()], + stroke, + ); + + // Draw circle at exact position + painter.circle_filled(egui::pos2(pos_x, pos_y), 6.0, CURSOR_COLOR); + painter.circle_stroke( + egui::pos2(pos_x, pos_y), + 6.0, + egui::Stroke::new(2.0, egui::Color32::WHITE), + ); + } + } + } + + // Handle hover tooltip + if let Some(pos) = response.hover_pos() { + if plot_rect.contains(pos) { + let rel_x = (pos.x - plot_rect.left()) / plot_rect.width(); + let rel_y = 1.0 - (pos.y - plot_rect.top()) / plot_rect.height(); + + if (0.0..=1.0).contains(&rel_x) && (0.0..=1.0).contains(&rel_y) { + let x_val = x_min + rel_x as f64 * x_range; + let y_val = y_min + rel_y as f64 * y_range; + + let x_bin = (rel_x * (grid_size - 1) as f32).round() as usize; + let y_bin = (rel_y * (grid_size - 1) as f32).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + let hits = hit_counts[y_bin][x_bin]; + let cell_value = cell_values[y_bin][x_bin]; + + let tooltip_text = match mode { + HistogramMode::HitCount => { + format!("X: {:.1}\nY: {:.1}\nHits: {}", x_val, y_val, hits) + } + HistogramMode::AverageZ => { + let avg = cell_value + .map(|v| format!("{:.2}", v)) + .unwrap_or("-".to_string()); + format!( + "X: {:.1}\nY: {:.1}\nAvg Z: {}\nHits: {}", + x_val, y_val, avg, hits + ) + } + }; + + // Draw hover crosshairs + let hover_x = plot_rect.left() + rel_x * plot_rect.width(); + let hover_y = plot_rect.top() + (1.0 - rel_y) * plot_rect.height(); + let crosshair_color = egui::Color32::from_rgb(255, 255, 0); + + painter.line_segment( + [ + egui::pos2(hover_x, plot_rect.top()), + egui::pos2(hover_x, plot_rect.bottom()), + ], + egui::Stroke::new(1.0, crosshair_color), + ); + painter.line_segment( + [ + egui::pos2(plot_rect.left(), hover_y), + egui::pos2(plot_rect.right(), hover_y), + ], + egui::Stroke::new(1.0, crosshair_color), + ); + + painter.text( + egui::pos2(plot_rect.right() - 10.0, plot_rect.top() + 15.0), + egui::Align2::RIGHT_TOP, + tooltip_text, + egui::FontId::proportional(12.0), + egui::Color32::WHITE, + ); + } + } + } + + // Handle click to select cell + if response.clicked() { + if let Some(pos) = response.interact_pointer_pos() { + if plot_rect.contains(pos) { + let rel_x = (pos.x - plot_rect.left()) / plot_rect.width(); + let rel_y = 1.0 - (pos.y - plot_rect.top()) / plot_rect.height(); + + if (0.0..=1.0).contains(&rel_x) && (0.0..=1.0).contains(&rel_y) { + let x_bin = (rel_x * (grid_size - 1) as f32).round() as usize; + let y_bin = (rel_y * (grid_size - 1) as f32).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + let hits = hit_counts[y_bin][x_bin]; + + // Calculate cell value ranges + let bin_width_x = x_range / grid_size as f64; + let bin_width_y = y_range / grid_size as f64; + let cell_x_min = x_min + x_bin as f64 * bin_width_x; + let cell_x_max = cell_x_min + bin_width_x; + let cell_y_min = y_min + y_bin as f64 * bin_width_y; + let cell_y_max = cell_y_min + bin_width_y; + + // Calculate statistics + let mean = if hits > 0 { + z_sums[y_bin][x_bin] / hits as f64 + } else { + 0.0 + }; + + let variance = if hits > 1 { + let n = hits as f64; + (z_sum_sq[y_bin][x_bin] - (z_sums[y_bin][x_bin].powi(2) / n)) + / (n - 1.0) + } else { + 0.0 + }; + + let std_dev = variance.sqrt(); + let cell_weight = z_sums[y_bin][x_bin]; + + let minimum = if hits > 0 && z_mins[y_bin][x_bin] != f64::MAX { + z_mins[y_bin][x_bin] + } else { + 0.0 + }; + + let maximum = if hits > 0 && z_maxs[y_bin][x_bin] != f64::MIN { + z_maxs[y_bin][x_bin] + } else { + 0.0 + }; + + let selected = SelectedHistogramCell { + x_bin, + y_bin, + x_range: (cell_x_min, cell_x_max), + y_range: (cell_y_min, cell_y_max), + hit_count: hits, + cell_weight, + variance, + std_dev, + minimum, + mean, + maximum, + }; + + self.tabs[tab_idx].histogram_state.config.selected_cell = Some(selected); + } + } + } + } + + // Render legend + ui.add_space(8.0); + self.render_histogram_legend(ui, min_value, max_value, x_data.len(), mode, grid_size); + } + + /// Get a color from the heat map gradient based on normalized value (0-1) + fn get_histogram_color(normalized: f64) -> egui::Color32 { + let t = normalized.clamp(0.0, 1.0); + let scaled = t * (HEAT_COLORS.len() - 1) as f64; + let idx = scaled.floor() as usize; + let frac = scaled - idx as f64; + + if idx >= HEAT_COLORS.len() - 1 { + let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; + return egui::Color32::from_rgb(c[0], c[1], c[2]); + } + + let c1 = HEAT_COLORS[idx]; + let c2 = HEAT_COLORS[idx + 1]; + + let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) as u8; + let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) as u8; + let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) as u8; + + egui::Color32::from_rgb(r, g, b) + } + + /// Render the legend with color scale and stats + fn render_histogram_legend( + &self, + ui: &mut egui::Ui, + min_value: f64, + max_value: f64, + total_points: usize, + mode: HistogramMode, + grid_size: usize, + ) { + ui.horizontal(|ui| { + ui.add_space(4.0); + + // Color scale legend + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .corner_radius(4) + .inner_margin(8.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + let label = match mode { + HistogramMode::HitCount => "Hits:", + HistogramMode::AverageZ => "Value:", + }; + ui.label( + egui::RichText::new(label) + .size(13.0) + .color(egui::Color32::WHITE), + ); + + // Color gradient bar + let (rect, _) = + ui.allocate_exact_size(egui::vec2(120.0, 18.0), egui::Sense::hover()); + + let painter = ui.painter(); + let steps = 30; + let step_width = rect.width() / steps as f32; + + for i in 0..steps { + let t = i as f64 / steps as f64; + let color = Self::get_histogram_color(t); + let x = rect.left() + i as f32 * step_width; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(x, rect.top()), + egui::vec2(step_width + 1.0, rect.height()), + ), + 0.0, + color, + ); + } + + ui.add_space(6.0); + let range_text = if mode == HistogramMode::HitCount { + format!("0-{:.0}", max_value) + } else { + format!("{:.1}-{:.1}", min_value, max_value) + }; + ui.label( + egui::RichText::new(range_text) + .size(13.0) + .color(egui::Color32::WHITE), + ); + }); + }); + + ui.add_space(16.0); + + // Stats panel + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .corner_radius(4) + .inner_margin(8.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Grid: {}x{}", grid_size, grid_size)) + .size(13.0) + .color(egui::Color32::WHITE), + ); + ui.add_space(12.0); + ui.label( + egui::RichText::new(format!("Total Points: {}", total_points)) + .size(13.0) + .color(egui::Color32::WHITE), + ); + }); + }); + }); + } + + /// Render histogram cell statistics in sidebar + pub fn render_histogram_stats(&self, ui: &mut egui::Ui) { + let Some(tab_idx) = self.active_tab else { + return; + }; + + let selected = &self.tabs[tab_idx].histogram_state.config.selected_cell; + + ui.add_space(10.0); + ui.separator(); + ui.add_space(5.0); + + ui.label( + egui::RichText::new("Cell Statistics") + .size(14.0) + .strong() + .color(egui::Color32::WHITE), + ); + + ui.add_space(5.0); + + if let Some(cell) = selected { + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200)) + .corner_radius(4) + .inner_margin(8.0) + .stroke(egui::Stroke::new(1.0, SELECTED_CELL_COLOR)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Cell [{}, {}]", cell.x_bin, cell.y_bin)) + .size(13.0) + .color(SELECTED_CELL_COLOR), + ); + }); + + ui.add_space(4.0); + + let stats = [ + ( + "X Range", + format!("{:.2} - {:.2}", cell.x_range.0, cell.x_range.1), + ), + ( + "Y Range", + format!("{:.2} - {:.2}", cell.y_range.0, cell.y_range.1), + ), + ("Hit Count", format!("{}", cell.hit_count)), + ("Cell Weight", format!("{:.4}", cell.cell_weight)), + ("Mean", format!("{:.4}", cell.mean)), + ("Minimum", format!("{:.4}", cell.minimum)), + ("Maximum", format!("{:.4}", cell.maximum)), + ("Variance", format!("{:.4}", cell.variance)), + ("Std Dev", format!("{:.4}", cell.std_dev)), + ]; + + for (label, value) in stats { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("{}:", label)) + .size(12.0) + .color(egui::Color32::from_rgb(180, 180, 180)), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + egui::RichText::new(&value) + .size(12.0) + .color(egui::Color32::WHITE), + ); + }, + ); + }); + } + + ui.add_space(4.0); + + if ui.small_button("Clear Selection").clicked() { + // We can't mutate here, set a flag instead + } + }); + } else { + ui.label( + egui::RichText::new("Click a cell to view statistics") + .size(12.0) + .italics() + .color(egui::Color32::GRAY), + ); + } + } +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 30a6b41..2e2c6ae 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -4,7 +4,7 @@ use eframe::egui; use crate::analytics; use crate::app::UltraLogApp; -use crate::state::LoadingState; +use crate::state::{ActiveTool, LoadingState}; use crate::units::{ AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, TemperatureUnit, VolumeUnit, @@ -49,22 +49,43 @@ impl UltraLogApp { ui.separator(); - // Export submenu + // Export submenu - context-aware based on active tool let has_chart_data = !self.files.is_empty() && !self.get_selected_channels().is_empty(); - ui.add_enabled_ui(has_chart_data, |ui| { + let has_histogram_data = !self.files.is_empty() + && self.active_tool == ActiveTool::Histogram + && self.active_tab.is_some() + && { + let tab_idx = self.active_tab.unwrap(); + let config = &self.tabs[tab_idx].histogram_state.config; + config.x_channel.is_some() && config.y_channel.is_some() + }; + + let can_export = has_chart_data || has_histogram_data; + + ui.add_enabled_ui(can_export, |ui| { ui.menu_button("📤 Export", |ui| { // Increase font size for submenu items ui.style_mut() .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui.button("Export as PNG...").clicked() { - self.export_chart_png(); - ui.close(); - } - if ui.button("Export as PDF...").clicked() { - self.export_chart_pdf(); - ui.close(); + + if self.active_tool == ActiveTool::Histogram && has_histogram_data { + // Histogram export options + if ui.button("Export Histogram as PDF...").clicked() { + self.export_histogram_pdf(); + ui.close(); + } + } else if has_chart_data { + // Chart export options + if ui.button("Export as PNG...").clicked() { + self.export_chart_png(); + ui.close(); + } + if ui.button("Export as PDF...").clicked() { + self.export_chart_pdf(); + ui.close(); + } } }); }); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bf02d77..f277bb9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,6 +20,7 @@ pub mod chart; pub mod computed_channels_manager; pub mod export; pub mod formula_editor; +pub mod histogram; pub mod icons; pub mod menu; pub mod normalization_editor; diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index efca203..dea7f2d 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -213,6 +213,14 @@ impl UltraLogApp { // Reverse order since we're bottom-up ui.add_space(10.0); + // Show histogram stats when in Histogram mode + if !self.files.is_empty() && self.active_tool == ActiveTool::Histogram { + self.render_histogram_stats(ui); + + ui.add_space(5.0); + ui.separator(); + } + // Only show options when we have data to view and are in Log Viewer mode if !self.files.is_empty() && !self.get_selected_channels().is_empty() diff --git a/src/ui/tool_switcher.rs b/src/ui/tool_switcher.rs index 140723b..90493f7 100644 --- a/src/ui/tool_switcher.rs +++ b/src/ui/tool_switcher.rs @@ -16,7 +16,11 @@ impl UltraLogApp { ui.add_space(10.0); // Define available tools - let tools = [ActiveTool::LogViewer, ActiveTool::ScatterPlot]; + let tools = [ + ActiveTool::LogViewer, + ActiveTool::ScatterPlot, + ActiveTool::Histogram, + ]; for tool in tools { let is_selected = self.active_tool == tool; diff --git a/tests/core/state_tests.rs b/tests/core/state_tests.rs index ead5594..f2e9d77 100644 --- a/tests/core/state_tests.rs +++ b/tests/core/state_tests.rs @@ -12,9 +12,10 @@ use ultralog::parsers::haltech::{ChannelType, HaltechChannel}; use ultralog::parsers::types::{EcuType, Log, Value}; use ultralog::parsers::Channel; use ultralog::state::{ - ActiveTool, CacheKey, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, - ScatterPlotState, SelectedChannel, SelectedHeatmapPoint, Tab, ToastType, CHART_COLORS, - COLORBLIND_COLORS, MAX_CHANNELS, MAX_CHART_POINTS, SUPPORTED_EXTENSIONS, + ActiveTool, CacheKey, HistogramConfig, HistogramGridSize, HistogramMode, HistogramState, + LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, ScatterPlotState, SelectedChannel, + SelectedHeatmapPoint, SelectedHistogramCell, Tab, ToastType, CHART_COLORS, COLORBLIND_COLORS, + MAX_CHANNELS, MAX_CHART_POINTS, SUPPORTED_EXTENSIONS, }; // ============================================ @@ -181,6 +182,7 @@ fn test_active_tool_default() { fn test_active_tool_names() { assert_eq!(ActiveTool::LogViewer.name(), "Log Viewer"); assert_eq!(ActiveTool::ScatterPlot.name(), "Scatter Plots"); + assert_eq!(ActiveTool::Histogram.name(), "Histogram"); } #[test] @@ -188,7 +190,10 @@ fn test_active_tool_equality() { // Use pattern matching since ActiveTool doesn't implement Debug assert!(ActiveTool::LogViewer == ActiveTool::LogViewer); assert!(ActiveTool::ScatterPlot == ActiveTool::ScatterPlot); + assert!(ActiveTool::Histogram == ActiveTool::Histogram); assert!(ActiveTool::LogViewer != ActiveTool::ScatterPlot); + assert!(ActiveTool::LogViewer != ActiveTool::Histogram); + assert!(ActiveTool::ScatterPlot != ActiveTool::Histogram); } #[test] @@ -596,3 +601,313 @@ fn test_loaded_file_clone() { assert_eq!(cloned.name, file.name); assert_eq!(cloned.channels_with_data, file.channels_with_data); } + +// ============================================ +// HistogramMode Tests +// ============================================ + +#[test] +fn test_histogram_mode_default() { + let mode = HistogramMode::default(); + assert!(matches!(mode, HistogramMode::AverageZ)); +} + +#[test] +fn test_histogram_mode_variants() { + let average_z = HistogramMode::AverageZ; + let hit_count = HistogramMode::HitCount; + + assert!(matches!(average_z, HistogramMode::AverageZ)); + assert!(matches!(hit_count, HistogramMode::HitCount)); +} + +#[test] +fn test_histogram_mode_equality() { + assert!(HistogramMode::AverageZ == HistogramMode::AverageZ); + assert!(HistogramMode::HitCount == HistogramMode::HitCount); + assert!(HistogramMode::AverageZ != HistogramMode::HitCount); +} + +#[test] +fn test_histogram_mode_copy() { + let mode1 = HistogramMode::HitCount; + let mode2 = mode1; + assert!(mode1 == mode2); +} + +// ============================================ +// HistogramGridSize Tests +// ============================================ + +#[test] +fn test_histogram_grid_size_default() { + let size = HistogramGridSize::default(); + // Default should be 32x32 + assert!(matches!(size, HistogramGridSize::Size32)); +} + +#[test] +fn test_histogram_grid_size_values() { + assert_eq!(HistogramGridSize::Size16.size(), 16); + assert_eq!(HistogramGridSize::Size32.size(), 32); + assert_eq!(HistogramGridSize::Size64.size(), 64); +} + +#[test] +fn test_histogram_grid_size_names() { + assert_eq!(HistogramGridSize::Size16.name(), "16x16"); + assert_eq!(HistogramGridSize::Size32.name(), "32x32"); + assert_eq!(HistogramGridSize::Size64.name(), "64x64"); +} + +#[test] +fn test_histogram_grid_size_equality() { + assert!(HistogramGridSize::Size16 == HistogramGridSize::Size16); + assert!(HistogramGridSize::Size32 == HistogramGridSize::Size32); + assert!(HistogramGridSize::Size64 == HistogramGridSize::Size64); + assert!(HistogramGridSize::Size16 != HistogramGridSize::Size32); + assert!(HistogramGridSize::Size32 != HistogramGridSize::Size64); +} + +#[test] +fn test_histogram_grid_size_copy() { + let size1 = HistogramGridSize::Size64; + let size2 = size1; + assert!(size1 == size2); +} + +// ============================================ +// SelectedHistogramCell Tests +// ============================================ + +#[test] +fn test_selected_histogram_cell_default() { + let cell = SelectedHistogramCell::default(); + + assert_eq!(cell.x_bin, 0); + assert_eq!(cell.y_bin, 0); + assert_eq!(cell.x_range, (0.0, 0.0)); + assert_eq!(cell.y_range, (0.0, 0.0)); + assert_eq!(cell.hit_count, 0); + assert_eq!(cell.cell_weight, 0.0); + assert_eq!(cell.variance, 0.0); + assert_eq!(cell.std_dev, 0.0); + assert_eq!(cell.minimum, 0.0); + assert_eq!(cell.mean, 0.0); + assert_eq!(cell.maximum, 0.0); +} + +#[test] +fn test_selected_histogram_cell_with_values() { + let cell = SelectedHistogramCell { + x_bin: 5, + y_bin: 10, + x_range: (100.0, 200.0), + y_range: (50.0, 75.0), + hit_count: 42, + cell_weight: 1234.56, + variance: 12.5, + std_dev: 3.54, + minimum: 10.0, + mean: 29.4, + maximum: 50.0, + }; + + assert_eq!(cell.x_bin, 5); + assert_eq!(cell.y_bin, 10); + assert_eq!(cell.x_range, (100.0, 200.0)); + assert_eq!(cell.y_range, (50.0, 75.0)); + assert_eq!(cell.hit_count, 42); + assert_eq!(cell.cell_weight, 1234.56); + assert_eq!(cell.variance, 12.5); + assert_eq!(cell.std_dev, 3.54); + assert_eq!(cell.minimum, 10.0); + assert_eq!(cell.mean, 29.4); + assert_eq!(cell.maximum, 50.0); +} + +#[test] +fn test_selected_histogram_cell_clone() { + let cell = SelectedHistogramCell { + x_bin: 3, + y_bin: 7, + x_range: (0.0, 100.0), + y_range: (0.0, 50.0), + hit_count: 100, + cell_weight: 500.0, + variance: 25.0, + std_dev: 5.0, + minimum: 5.0, + mean: 25.0, + maximum: 45.0, + }; + + let cloned = cell.clone(); + + assert_eq!(cloned.x_bin, cell.x_bin); + assert_eq!(cloned.y_bin, cell.y_bin); + assert_eq!(cloned.x_range, cell.x_range); + assert_eq!(cloned.y_range, cell.y_range); + assert_eq!(cloned.hit_count, cell.hit_count); + assert_eq!(cloned.cell_weight, cell.cell_weight); + assert_eq!(cloned.variance, cell.variance); + assert_eq!(cloned.std_dev, cell.std_dev); + assert_eq!(cloned.minimum, cell.minimum); + assert_eq!(cloned.mean, cell.mean); + assert_eq!(cloned.maximum, cell.maximum); +} + +// ============================================ +// HistogramConfig Tests +// ============================================ + +#[test] +fn test_histogram_config_default() { + let config = HistogramConfig::default(); + + assert!(config.x_channel.is_none()); + assert!(config.y_channel.is_none()); + assert!(config.z_channel.is_none()); + assert!(matches!(config.mode, HistogramMode::AverageZ)); + assert!(matches!(config.grid_size, HistogramGridSize::Size32)); + assert!(config.selected_cell.is_none()); +} + +#[test] +fn test_histogram_config_with_values() { + let mut config = HistogramConfig::default(); + config.x_channel = Some(0); + config.y_channel = Some(1); + config.z_channel = Some(2); + config.mode = HistogramMode::HitCount; + config.grid_size = HistogramGridSize::Size64; + config.selected_cell = Some(SelectedHistogramCell::default()); + + assert_eq!(config.x_channel, Some(0)); + assert_eq!(config.y_channel, Some(1)); + assert_eq!(config.z_channel, Some(2)); + assert!(matches!(config.mode, HistogramMode::HitCount)); + assert!(matches!(config.grid_size, HistogramGridSize::Size64)); + assert!(config.selected_cell.is_some()); +} + +#[test] +fn test_histogram_config_clone() { + let mut config = HistogramConfig::default(); + config.x_channel = Some(5); + config.y_channel = Some(10); + config.mode = HistogramMode::HitCount; + + let cloned = config.clone(); + + assert_eq!(cloned.x_channel, Some(5)); + assert_eq!(cloned.y_channel, Some(10)); + assert!(matches!(cloned.mode, HistogramMode::HitCount)); +} + +// ============================================ +// HistogramState Tests +// ============================================ + +#[test] +fn test_histogram_state_default() { + let state = HistogramState::default(); + + assert!(state.config.x_channel.is_none()); + assert!(state.config.y_channel.is_none()); + assert!(state.config.z_channel.is_none()); +} + +#[test] +fn test_histogram_state_clone() { + let mut state = HistogramState::default(); + state.config.x_channel = Some(1); + state.config.y_channel = Some(2); + state.config.z_channel = Some(3); + state.config.mode = HistogramMode::HitCount; + state.config.grid_size = HistogramGridSize::Size16; + + let cloned = state.clone(); + + assert_eq!(cloned.config.x_channel, Some(1)); + assert_eq!(cloned.config.y_channel, Some(2)); + assert_eq!(cloned.config.z_channel, Some(3)); + assert!(matches!(cloned.config.mode, HistogramMode::HitCount)); + assert!(matches!(cloned.config.grid_size, HistogramGridSize::Size16)); +} + +// ============================================ +// Tab Histogram State Tests +// ============================================ + +#[test] +fn test_tab_histogram_state_initialization() { + let tab = Tab::new(0, "test.csv".to_string()); + + // Histogram state should be initialized with defaults + assert!(tab.histogram_state.config.x_channel.is_none()); + assert!(tab.histogram_state.config.y_channel.is_none()); + assert!(tab.histogram_state.config.z_channel.is_none()); + assert!(matches!( + tab.histogram_state.config.mode, + HistogramMode::AverageZ + )); + assert!(matches!( + tab.histogram_state.config.grid_size, + HistogramGridSize::Size32 + )); + assert!(tab.histogram_state.config.selected_cell.is_none()); +} + +#[test] +fn test_tab_histogram_state_persists_across_clone() { + let mut tab = Tab::new(0, "test.csv".to_string()); + tab.histogram_state.config.x_channel = Some(5); + tab.histogram_state.config.y_channel = Some(10); + tab.histogram_state.config.z_channel = Some(15); + tab.histogram_state.config.mode = HistogramMode::HitCount; + tab.histogram_state.config.grid_size = HistogramGridSize::Size64; + + let cloned = tab.clone(); + + assert_eq!(cloned.histogram_state.config.x_channel, Some(5)); + assert_eq!(cloned.histogram_state.config.y_channel, Some(10)); + assert_eq!(cloned.histogram_state.config.z_channel, Some(15)); + assert!(matches!( + cloned.histogram_state.config.mode, + HistogramMode::HitCount + )); + assert!(matches!( + cloned.histogram_state.config.grid_size, + HistogramGridSize::Size64 + )); +} + +#[test] +fn test_tab_histogram_selected_cell_persists() { + let mut tab = Tab::new(0, "test.csv".to_string()); + + let selected = SelectedHistogramCell { + x_bin: 8, + y_bin: 12, + x_range: (0.0, 500.0), + y_range: (0.0, 100.0), + hit_count: 50, + cell_weight: 250.0, + variance: 10.0, + std_dev: 3.16, + minimum: 2.0, + mean: 5.0, + maximum: 10.0, + }; + + tab.histogram_state.config.selected_cell = Some(selected); + + let cloned = tab.clone(); + + assert!(cloned.histogram_state.config.selected_cell.is_some()); + let cloned_cell = cloned.histogram_state.config.selected_cell.unwrap(); + assert_eq!(cloned_cell.x_bin, 8); + assert_eq!(cloned_cell.y_bin, 12); + assert_eq!(cloned_cell.hit_count, 50); +} From cd0c987b5f2d6f9701377b5f2c5f22440955d6bf Mon Sep 17 00:00:00 2001 From: James Barney Date: Tue, 30 Dec 2025 21:57:31 -0500 Subject: [PATCH 02/13] Brand New Analysis Tools for Complex Data Investigation (#28) * Fix Windows build: disable winresource for paths with spaces - Temporarily disable Windows resource embedding in build.rs - winresource has issues with paths containing spaces - Only affects Windows builds (inside cfg(windows) block) - Mac/Linux builds unaffected * analysis modules * undo temp * Some bug fixes to the analysis tool to make it more reliable Signed-off-by: Cole Gentry * Fixes tests Signed-off-by: Cole Gentry --------- Signed-off-by: Cole Gentry Co-authored-by: Cole Gentry --- build.rs | 1 + src/analysis/afr.rs | 823 ++++++++++++++++++ src/analysis/derived.rs | 556 ++++++++++++ src/analysis/filters.rs | 1235 +++++++++++++++++++++++++++ src/analysis/mod.rs | 346 ++++++++ src/analysis/statistics.rs | 602 +++++++++++++ src/app.rs | 12 + src/computed.rs | 150 +++- src/expression.rs | 115 ++- src/lib.rs | 2 + src/normalize.rs | 62 +- src/ui/analysis_panel.rs | 1082 +++++++++++++++++++++++ src/ui/channels.rs | 96 ++- src/ui/computed_channels_manager.rs | 279 ++++-- src/ui/menu.rs | 7 + src/ui/mod.rs | 2 + src/ui/toast.rs | 3 + tests/core/normalize_tests.rs | 21 +- 18 files changed, 5266 insertions(+), 128 deletions(-) create mode 100644 src/analysis/afr.rs create mode 100644 src/analysis/derived.rs create mode 100644 src/analysis/filters.rs create mode 100644 src/analysis/mod.rs create mode 100644 src/analysis/statistics.rs create mode 100644 src/ui/analysis_panel.rs diff --git a/build.rs b/build.rs index 73f2cb0..a9ddd09 100644 --- a/build.rs +++ b/build.rs @@ -6,6 +6,7 @@ fn main() { // Note: Windows resource embedding requires an .ico file // Convert assets/icons/windows.png to .ico format if needed: // magick assets/icons/windows.png -define icon:auto-resize=256,128,64,48,32,16 assets/icons/windows.ico + let mut res = winresource::WindowsResource::new(); res.set_icon("assets/icons/windows.ico") .set("ProductName", "UltraLog") diff --git a/src/analysis/afr.rs b/src/analysis/afr.rs new file mode 100644 index 0000000..15a9bc7 --- /dev/null +++ b/src/analysis/afr.rs @@ -0,0 +1,823 @@ +//! Air-Fuel Ratio analysis algorithms. +//! +//! Provides AFR-related analysis including fuel trim drift detection (CUSUM), +//! rich/lean zone detection, and AFR deviation analysis. +//! +//! Supports both AFR and Lambda units with automatic detection. + +use super::*; +use std::collections::HashMap; + +// ============================================================================ +// Lambda/AFR unit detection and conversion +// ============================================================================ + +/// Stoichiometric AFR for gasoline (14.7:1) +pub const STOICH_AFR_GASOLINE: f64 = 14.7; + +/// Stoichiometric Lambda (always 1.0 by definition) +pub const STOICH_LAMBDA: f64 = 1.0; + +/// Detected fuel mixture unit type +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FuelMixtureUnit { + /// Air-Fuel Ratio (typical range 10-20 for gasoline) + Afr, + /// Lambda (typical range 0.7-1.3, stoich = 1.0) + Lambda, +} + +impl FuelMixtureUnit { + /// Get the stoichiometric value for this unit type + pub fn stoichiometric(&self) -> f64 { + match self { + FuelMixtureUnit::Afr => STOICH_AFR_GASOLINE, + FuelMixtureUnit::Lambda => STOICH_LAMBDA, + } + } + + /// Get appropriate rich/lean thresholds for this unit type + pub fn default_thresholds(&self) -> (f64, f64) { + match self { + // AFR: ±0.5 from 14.7 (roughly ±3.4%) + FuelMixtureUnit::Afr => (0.5, 0.5), + // Lambda: ±0.03 from 1.0 (roughly ±3%) + FuelMixtureUnit::Lambda => (0.03, 0.03), + } + } + + /// Convert a value to AFR (for consistent analysis) + pub fn to_afr(&self, value: f64) -> f64 { + match self { + FuelMixtureUnit::Afr => value, + FuelMixtureUnit::Lambda => value * STOICH_AFR_GASOLINE, + } + } + + /// Get unit name for display + pub fn unit_name(&self) -> &'static str { + match self { + FuelMixtureUnit::Afr => "AFR", + FuelMixtureUnit::Lambda => "λ", + } + } +} + +/// Detect whether data is in Lambda or AFR units based on value distribution +/// +/// Uses the median value to determine unit type: +/// - Lambda: typically 0.7-1.3 (median around 1.0) +/// - AFR: typically 10-20 (median around 14.7) +pub fn detect_fuel_mixture_unit(data: &[f64]) -> FuelMixtureUnit { + if data.is_empty() { + return FuelMixtureUnit::Afr; // Default assumption + } + + // Calculate median for robust detection (less affected by outliers) + let mut sorted = data.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + #[allow(clippy::manual_is_multiple_of)] + let median = if sorted.len() % 2 == 0 { + (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0 + } else { + sorted[sorted.len() / 2] + }; + + // Lambda values cluster around 1.0, AFR values cluster around 14.7 + // Use 3.0 as the threshold (well above Lambda range, well below AFR range) + if median < 3.0 { + FuelMixtureUnit::Lambda + } else { + FuelMixtureUnit::Afr + } +} + +/// Fuel Trim Drift Analyzer using CUSUM algorithm +/// +/// Detects gradual drift in long-term fuel trim that may indicate +/// injector degradation, air leaks, or sensor aging. +#[derive(Clone)] +pub struct FuelTrimDriftAnalyzer { + /// Channel to analyze (typically LTFT or STFT) + pub channel: String, + /// Allowable slack parameter k (typically 0.5σ) + /// Controls sensitivity to small shifts + pub k: f64, + /// Decision threshold h (typically 4-5σ) + /// Higher values = fewer false alarms, slower detection + pub h: f64, + /// Baseline calculation window (percentage of data from start) + pub baseline_pct: f64, +} + +impl Default for FuelTrimDriftAnalyzer { + fn default() -> Self { + Self { + channel: "LTFT".to_string(), + k: 2.5, // 0.5 * typical 5% σ + h: 20.0, // 4 * typical 5% σ + baseline_pct: 10.0, // Use first 10% for baseline + } + } +} + +impl Analyzer for FuelTrimDriftAnalyzer { + fn id(&self) -> &str { + "fuel_trim_drift" + } + + fn name(&self) -> &str { + "Fuel Trim Drift Detection" + } + + fn description(&self) -> &str { + "CUSUM algorithm detecting gradual long-term fuel trim drift indicating \ + injector degradation, air leaks, or sensor aging. Returns drift indicator: \ + +1 = rich drift, -1 = lean drift, 0 = normal." + } + + fn category(&self) -> &str { + "AFR" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 100)?; + + let (result_data, computation_time) = + timed_analyze(|| cusum_drift_detection(&data, self.k, self.h, self.baseline_pct)); + + // Count drift events for warnings + let high_drift_samples = result_data.drift_flags.iter().filter(|&&x| x > 0.5).count(); + let low_drift_samples = result_data + .drift_flags + .iter() + .filter(|&&x| x < -0.5) + .count(); + let total = data.len(); + + let mut warnings = vec![]; + + if high_drift_samples > total / 20 { + warnings.push(format!( + "Sustained positive drift detected ({:.1}% of samples) - running rich, check for over-fueling", + 100.0 * high_drift_samples as f64 / total as f64 + )); + } + if low_drift_samples > total / 20 { + warnings.push(format!( + "Sustained negative drift detected ({:.1}% of samples) - running lean, check for air leaks", + 100.0 * low_drift_samples as f64 / total as f64 + )); + } + + Ok(AnalysisResult { + name: format!("{} Drift", self.channel), + unit: "drift".to_string(), + values: result_data.drift_flags, + metadata: AnalysisMetadata { + algorithm: "CUSUM".to_string(), + parameters: vec![ + ("k".to_string(), format!("{:.2}", self.k)), + ("h".to_string(), format!("{:.2}", self.h)), + ( + "baseline_μ".to_string(), + format!("{:.2}%", result_data.baseline_mean), + ), + ( + "baseline_σ".to_string(), + format!("{:.2}%", result_data.baseline_stdev), + ), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("k".to_string(), self.k.to_string()); + params.insert("h".to_string(), self.h.to_string()); + params.insert("baseline_pct".to_string(), self.baseline_pct.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(v) = config.parameters.get("k") { + if let Ok(val) = v.parse() { + self.k = val; + } + } + if let Some(v) = config.parameters.get("h") { + if let Ok(val) = v.parse() { + self.h = val; + } + } + if let Some(v) = config.parameters.get("baseline_pct") { + if let Ok(val) = v.parse() { + self.baseline_pct = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Rich/Lean Zone Analyzer +/// +/// Detects periods where AFR/Lambda deviates significantly from target, +/// classifying into rich, lean, and stoichiometric zones. +/// Automatically detects whether data is in AFR or Lambda units. +#[derive(Clone)] +pub struct RichLeanZoneAnalyzer { + /// AFR/Lambda channel to analyze + pub channel: String, + /// Target value (default: auto-detect based on data) + /// Set to 0.0 for auto-detection + pub target: f64, + /// Rich threshold (below target - this value) + /// Set to 0.0 for auto-detection based on unit type + pub rich_threshold: f64, + /// Lean threshold (above target + this value) + /// Set to 0.0 for auto-detection based on unit type + pub lean_threshold: f64, +} + +impl Default for RichLeanZoneAnalyzer { + fn default() -> Self { + Self { + channel: "AFR".to_string(), + target: 0.0, // Auto-detect + rich_threshold: 0.0, // Auto-detect + lean_threshold: 0.0, // Auto-detect + } + } +} + +impl Analyzer for RichLeanZoneAnalyzer { + fn id(&self) -> &str { + "rich_lean_zone" + } + + fn name(&self) -> &str { + "Rich/Lean Zone Detection" + } + + fn description(&self) -> &str { + "Classifies AFR/Lambda readings into rich (-1), stoichiometric (0), and lean (+1) zones \ + based on deviation from target. Automatically detects AFR vs Lambda units." + } + + fn category(&self) -> &str { + "AFR" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 10)?; + + // Auto-detect unit type + let unit = detect_fuel_mixture_unit(&data); + + // Use configured values or auto-detect based on unit type + let target = if self.target > 0.0 { + self.target + } else { + unit.stoichiometric() + }; + + let (rich_thresh, lean_thresh) = if self.rich_threshold > 0.0 && self.lean_threshold > 0.0 { + (self.rich_threshold, self.lean_threshold) + } else { + unit.default_thresholds() + }; + + let rich_limit = target - rich_thresh; + let lean_limit = target + lean_thresh; + + let (zones, computation_time) = timed_analyze(|| { + data.iter() + .map(|&value| { + if value < rich_limit { + -1.0 // Rich + } else if value > lean_limit { + 1.0 // Lean + } else { + 0.0 // Stoichiometric + } + }) + .collect::>() + }); + + // Count time in each zone + let rich_count = zones.iter().filter(|&&z| z < -0.5).count(); + let lean_count = zones.iter().filter(|&&z| z > 0.5).count(); + let stoich_count = zones.iter().filter(|&&z| z.abs() < 0.5).count(); + let total = zones.len() as f64; + + let rich_pct = 100.0 * rich_count as f64 / total; + let lean_pct = 100.0 * lean_count as f64 / total; + let stoich_pct = 100.0 * stoich_count as f64 / total; + + let mut warnings = vec![]; + + if rich_pct > 30.0 { + warnings.push(format!( + "Excessive rich operation ({:.1}%) - may indicate over-fueling or cold conditions", + rich_pct + )); + } + if lean_pct > 30.0 { + warnings.push(format!( + "Excessive lean operation ({:.1}%) - check for air leaks or fuel delivery issues", + lean_pct + )); + } + + // Format limits based on detected unit + let precision = if unit == FuelMixtureUnit::Lambda { + 2 + } else { + 1 + }; + + Ok(AnalysisResult { + name: format!("{} Zone", self.channel), + unit: "zone".to_string(), + values: zones, + metadata: AnalysisMetadata { + algorithm: "Threshold Classification".to_string(), + parameters: vec![ + ("detected_unit".to_string(), unit.unit_name().to_string()), + ( + "target".to_string(), + format!("{:.prec$}", target, prec = precision), + ), + ( + "rich_limit".to_string(), + format!("{:.prec$}", rich_limit, prec = precision), + ), + ( + "lean_limit".to_string(), + format!("{:.prec$}", lean_limit, prec = precision), + ), + ("rich_pct".to_string(), format!("{:.1}%", rich_pct)), + ("stoich_pct".to_string(), format!("{:.1}%", stoich_pct)), + ("lean_pct".to_string(), format!("{:.1}%", lean_pct)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("target".to_string(), self.target.to_string()); + params.insert( + "rich_threshold".to_string(), + self.rich_threshold.to_string(), + ); + params.insert( + "lean_threshold".to_string(), + self.lean_threshold.to_string(), + ); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(v) = config.parameters.get("target") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + // Support legacy "target_afr" parameter name + if let Some(v) = config.parameters.get("target_afr") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + if let Some(v) = config.parameters.get("rich_threshold") { + if let Ok(val) = v.parse() { + self.rich_threshold = val; + } + } + if let Some(v) = config.parameters.get("lean_threshold") { + if let Ok(val) = v.parse() { + self.lean_threshold = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// AFR/Lambda Deviation Analyzer +/// +/// Computes percentage deviation from target AFR/Lambda, useful for +/// fuel table correction calculations. +/// Automatically detects whether data is in AFR or Lambda units. +#[derive(Clone)] +pub struct AfrDeviationAnalyzer { + /// AFR/Lambda channel to analyze + pub channel: String, + /// Target value (default: auto-detect based on data) + /// Set to 0.0 for auto-detection + pub target: f64, +} + +impl Default for AfrDeviationAnalyzer { + fn default() -> Self { + Self { + channel: "AFR".to_string(), + target: 0.0, // Auto-detect + } + } +} + +impl Analyzer for AfrDeviationAnalyzer { + fn id(&self) -> &str { + "afr_deviation" + } + + fn name(&self) -> &str { + "AFR/Lambda Deviation %" + } + + fn description(&self) -> &str { + "Computes percentage deviation from target AFR/Lambda. Positive = lean, negative = rich. \ + Automatically detects AFR vs Lambda units. Useful for fuel table corrections." + } + + fn category(&self) -> &str { + "AFR" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + // Auto-detect unit type + let unit = detect_fuel_mixture_unit(&data); + + // Use configured target or auto-detect based on unit type + let target = if self.target > 0.0 { + self.target + } else { + unit.stoichiometric() + }; + + if target <= 0.0 { + return Err(AnalysisError::InvalidParameter( + "Target must be positive".to_string(), + )); + } + + let (deviations, computation_time) = timed_analyze(|| { + data.iter() + .map(|&value| ((value - target) / target) * 100.0) + .collect::>() + }); + + // Compute statistics on deviations + let stats = super::statistics::compute_descriptive_stats(&deviations); + + let mut warnings = vec![]; + + if stats.mean.abs() > 5.0 { + let direction = if stats.mean > 0.0 { "lean" } else { "rich" }; + warnings.push(format!( + "Significant average {} bias ({:.1}%) - consider fuel table adjustment", + direction, stats.mean + )); + } + + if stats.stdev > 10.0 { + warnings.push(format!( + "High {} variability (σ={:.1}%) - check sensor or tune stability", + unit.unit_name(), + stats.stdev + )); + } + + // Format target based on detected unit + let precision = if unit == FuelMixtureUnit::Lambda { + 2 + } else { + 1 + }; + + Ok(AnalysisResult { + name: format!("{} Deviation", self.channel), + unit: "%".to_string(), + values: deviations, + metadata: AnalysisMetadata { + algorithm: "Percentage Deviation".to_string(), + parameters: vec![ + ("detected_unit".to_string(), unit.unit_name().to_string()), + ( + "target".to_string(), + format!("{:.prec$}", target, prec = precision), + ), + ("mean_deviation".to_string(), format!("{:.2}%", stats.mean)), + ("stdev".to_string(), format!("{:.2}%", stats.stdev)), + ("max_deviation".to_string(), format!("{:.2}%", stats.max)), + ("min_deviation".to_string(), format!("{:.2}%", stats.min)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("target".to_string(), self.target.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(v) = config.parameters.get("target") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + // Support legacy "target_afr" parameter name + if let Some(v) = config.parameters.get("target_afr") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core AFR algorithm implementations +// ============================================================================ + +/// Result of CUSUM drift detection +struct CusumResult { + drift_flags: Vec, + baseline_mean: f64, + baseline_stdev: f64, +} + +/// CUSUM (Cumulative Sum) drift detection algorithm +/// +/// Detects gradual shifts from baseline mean. +/// - k: allowable slack (sensitivity), typically 0.5σ +/// - h: decision threshold, typically 4-5σ +fn cusum_drift_detection(data: &[f64], k: f64, h: f64, baseline_pct: f64) -> CusumResult { + if data.is_empty() { + return CusumResult { + drift_flags: vec![], + baseline_mean: 0.0, + baseline_stdev: 1.0, + }; + } + + // Calculate baseline statistics from initial portion of data + let baseline_len = ((data.len() as f64 * baseline_pct / 100.0) as usize).max(10); + let baseline_data = &data[..baseline_len.min(data.len())]; + + let baseline_mean: f64 = baseline_data.iter().sum::() / baseline_data.len() as f64; + let baseline_variance: f64 = baseline_data + .iter() + .map(|x| (x - baseline_mean).powi(2)) + .sum::() + / (baseline_data.len() - 1).max(1) as f64; + let baseline_stdev = baseline_variance.sqrt().max(0.001); + + // CUSUM calculation + let mut s_high = 0.0; + let mut s_low = 0.0; + let mut drift_flags = Vec::with_capacity(data.len()); + + for &x in data { + // Update CUSUM statistics + s_high = (s_high + (x - baseline_mean) - k).max(0.0); + s_low = (s_low + (-x + baseline_mean) - k).max(0.0); + + // Determine drift direction + let flag = if s_high > h { + 1.0 // Positive drift (running rich if this is fuel trim) + } else if s_low > h { + -1.0 // Negative drift (running lean if this is fuel trim) + } else { + 0.0 // Normal + }; + drift_flags.push(flag); + + // Reset after detection (optional - prevents persistent flagging) + if s_high > h { + s_high = 0.0; + } + if s_low > h { + s_low = 0.0; + } + } + + CusumResult { + drift_flags, + baseline_mean, + baseline_stdev, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_afr_unit() { + // Typical AFR data (around 14.7) + let afr_data = vec![14.5, 14.7, 14.9, 15.0, 14.2, 14.8]; + assert_eq!(detect_fuel_mixture_unit(&afr_data), FuelMixtureUnit::Afr); + + // Wide range AFR data + let afr_wide = vec![12.0, 13.5, 14.7, 16.0, 18.0]; + assert_eq!(detect_fuel_mixture_unit(&afr_wide), FuelMixtureUnit::Afr); + } + + #[test] + fn test_detect_lambda_unit() { + // Typical Lambda data (around 1.0) + let lambda_data = vec![0.95, 1.0, 1.02, 0.98, 1.05, 0.97]; + assert_eq!( + detect_fuel_mixture_unit(&lambda_data), + FuelMixtureUnit::Lambda + ); + + // Rich lambda data + let lambda_rich = vec![0.75, 0.80, 0.85, 0.90]; + assert_eq!( + detect_fuel_mixture_unit(&lambda_rich), + FuelMixtureUnit::Lambda + ); + + // Lean lambda data + let lambda_lean = vec![1.05, 1.10, 1.15, 1.20]; + assert_eq!( + detect_fuel_mixture_unit(&lambda_lean), + FuelMixtureUnit::Lambda + ); + } + + #[test] + fn test_fuel_mixture_unit_stoichiometric() { + assert!((FuelMixtureUnit::Afr.stoichiometric() - 14.7).abs() < 0.01); + assert!((FuelMixtureUnit::Lambda.stoichiometric() - 1.0).abs() < 0.01); + } + + #[test] + fn test_fuel_mixture_unit_to_afr() { + // AFR stays the same + assert!((FuelMixtureUnit::Afr.to_afr(14.7) - 14.7).abs() < 0.01); + + // Lambda 1.0 converts to 14.7 + assert!((FuelMixtureUnit::Lambda.to_afr(1.0) - 14.7).abs() < 0.01); + + // Lambda 0.9 (rich) converts to ~13.2 + assert!((FuelMixtureUnit::Lambda.to_afr(0.9) - 13.23).abs() < 0.1); + } + + #[test] + fn test_cusum_stable() { + // Stable data around 0 should not trigger drift + let data: Vec = (0..200).map(|_| 0.5).collect(); + let result = cusum_drift_detection(&data, 2.5, 20.0, 10.0); + + let drift_count = result.drift_flags.iter().filter(|&&x| x != 0.0).count(); + assert_eq!(drift_count, 0, "Stable data should have no drift"); + } + + #[test] + fn test_cusum_drift_up() { + // Data that drifts upward + let mut data: Vec = vec![0.0; 100]; + data.extend(vec![10.0; 100]); // Step change + + let result = cusum_drift_detection(&data, 2.5, 20.0, 10.0); + + let drift_count = result.drift_flags.iter().filter(|&&x| x > 0.0).count(); + assert!(drift_count > 0, "Upward drift should be detected"); + } + + #[test] + fn test_rich_lean_zones_afr() { + // Test with AFR data + let afr_data = vec![14.7, 14.0, 15.5, 14.7, 13.5, 16.0]; + let unit = detect_fuel_mixture_unit(&afr_data); + assert_eq!(unit, FuelMixtureUnit::Afr); + + let (rich_thresh, lean_thresh) = unit.default_thresholds(); + let target = unit.stoichiometric(); + let rich_limit = target - rich_thresh; + let lean_limit = target + lean_thresh; + + // Count expected zones + let rich = afr_data.iter().filter(|&&a| a < rich_limit).count(); + let lean = afr_data.iter().filter(|&&a| a > lean_limit).count(); + + assert!(rich > 0, "Should detect rich conditions"); + assert!(lean > 0, "Should detect lean conditions"); + } + + #[test] + fn test_rich_lean_zones_lambda() { + // Test with Lambda data + let lambda_data = vec![1.0, 0.95, 1.05, 1.0, 0.90, 1.10]; + let unit = detect_fuel_mixture_unit(&lambda_data); + assert_eq!(unit, FuelMixtureUnit::Lambda); + + let (rich_thresh, lean_thresh) = unit.default_thresholds(); + let target = unit.stoichiometric(); + let rich_limit = target - rich_thresh; + let lean_limit = target + lean_thresh; + + // Count expected zones + let rich = lambda_data.iter().filter(|&&a| a < rich_limit).count(); + let lean = lambda_data.iter().filter(|&&a| a > lean_limit).count(); + + assert!(rich > 0, "Should detect rich conditions in lambda data"); + assert!(lean > 0, "Should detect lean conditions in lambda data"); + } + + #[test] + fn test_afr_deviation() { + let afr_data = vec![14.7, 15.435, 13.965]; // 0%, +5%, -5% + let target = 14.7; + + let deviations: Vec = afr_data + .iter() + .map(|&afr| ((afr - target) / target) * 100.0) + .collect(); + + assert!((deviations[0] - 0.0).abs() < 0.1); + assert!((deviations[1] - 5.0).abs() < 0.1); + assert!((deviations[2] + 5.0).abs() < 0.1); + } + + #[test] + fn test_lambda_deviation() { + let lambda_data = vec![1.0, 1.05, 0.95]; // 0%, +5%, -5% + let target = 1.0; + + let deviations: Vec = lambda_data + .iter() + .map(|&lambda| ((lambda - target) / target) * 100.0) + .collect(); + + assert!((deviations[0] - 0.0).abs() < 0.1); + assert!((deviations[1] - 5.0).abs() < 0.1); + assert!((deviations[2] + 5.0).abs() < 0.1); + } +} diff --git a/src/analysis/derived.rs b/src/analysis/derived.rs new file mode 100644 index 0000000..6af8434 --- /dev/null +++ b/src/analysis/derived.rs @@ -0,0 +1,556 @@ +//! Derived calculation algorithms. +//! +//! Computes derived engine metrics like Volumetric Efficiency, +//! Injector Duty Cycle, and other calculated values. + +use super::*; +use std::collections::HashMap; + +/// Volumetric Efficiency Analyzer +/// +/// Estimates VE from MAF and engine parameters, or from MAP/IAT using +/// speed-density equations. +#[derive(Clone)] +pub struct VolumetricEfficiencyAnalyzer { + /// RPM channel + pub rpm_channel: String, + /// MAP channel (kPa) + pub map_channel: String, + /// IAT channel (°C or K - set is_iat_kelvin accordingly) + pub iat_channel: String, + /// Engine displacement in liters + pub displacement_l: f64, + /// Whether IAT is already in Kelvin (false = Celsius) + pub is_iat_kelvin: bool, +} + +impl Default for VolumetricEfficiencyAnalyzer { + fn default() -> Self { + Self { + rpm_channel: "RPM".to_string(), + map_channel: "MAP".to_string(), + iat_channel: "IAT".to_string(), + displacement_l: 2.0, // Default 2.0L engine + is_iat_kelvin: false, + } + } +} + +impl Analyzer for VolumetricEfficiencyAnalyzer { + fn id(&self) -> &str { + "volumetric_efficiency" + } + + fn name(&self) -> &str { + "Volumetric Efficiency" + } + + fn description(&self) -> &str { + "Estimates Volumetric Efficiency (VE) from MAP, RPM, and IAT using the \ + speed-density equation. VE = (MAP × 2) / (ρ_ref × Displacement × RPM × 60). \ + Requires engine displacement to be configured." + } + + fn category(&self) -> &str { + "Derived" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.rpm_channel, &self.map_channel, &self.iat_channel] + } + + fn analyze(&self, log: &Log) -> Result { + let rpm = require_channel(log, &self.rpm_channel)?; + let map = require_channel(log, &self.map_channel)?; + let iat = require_channel(log, &self.iat_channel)?; + + if rpm.len() != map.len() || rpm.len() != iat.len() { + return Err(AnalysisError::ComputationError( + "Channels have different lengths".to_string(), + )); + } + + require_min_length(&rpm, 2)?; + + if self.displacement_l <= 0.0 { + return Err(AnalysisError::InvalidParameter( + "Displacement must be positive".to_string(), + )); + } + + let (ve_values, computation_time) = timed_analyze(|| { + compute_volumetric_efficiency(&rpm, &map, &iat, self.displacement_l, self.is_iat_kelvin) + }); + + // Compute statistics + let stats = super::statistics::compute_descriptive_stats(&ve_values); + + let mut warnings = vec![]; + + // VE typically ranges from 70-110% for naturally aspirated, + // and can exceed 100% for forced induction + if stats.max > 150.0 { + warnings.push(format!( + "Very high VE detected (max {:.1}%) - verify displacement setting or check for sensor issues", + stats.max + )); + } + if stats.min < 20.0 && stats.min > 0.0 { + warnings.push(format!( + "Very low VE detected (min {:.1}%) - possible manifold leak or sensor issue", + stats.min + )); + } + + Ok(AnalysisResult { + name: "Volumetric Efficiency".to_string(), + unit: "%".to_string(), + values: ve_values, + metadata: AnalysisMetadata { + algorithm: "Speed-Density".to_string(), + parameters: vec![ + ( + "displacement_l".to_string(), + format!("{:.2}L", self.displacement_l), + ), + ("mean_ve".to_string(), format!("{:.1}%", stats.mean)), + ("max_ve".to_string(), format!("{:.1}%", stats.max)), + ("min_ve".to_string(), format!("{:.1}%", stats.min)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("rpm_channel".to_string(), self.rpm_channel.clone()); + params.insert("map_channel".to_string(), self.map_channel.clone()); + params.insert("iat_channel".to_string(), self.iat_channel.clone()); + params.insert( + "displacement_l".to_string(), + self.displacement_l.to_string(), + ); + params.insert("is_iat_kelvin".to_string(), self.is_iat_kelvin.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("rpm_channel") { + self.rpm_channel = ch.clone(); + } + if let Some(ch) = config.parameters.get("map_channel") { + self.map_channel = ch.clone(); + } + if let Some(ch) = config.parameters.get("iat_channel") { + self.iat_channel = ch.clone(); + } + if let Some(v) = config.parameters.get("displacement_l") { + if let Ok(val) = v.parse() { + self.displacement_l = val; + } + } + if let Some(v) = config.parameters.get("is_iat_kelvin") { + self.is_iat_kelvin = v.parse().unwrap_or(false); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Injector Duty Cycle Analyzer +/// +/// Calculates injector duty cycle as a percentage, important for +/// determining if injectors are reaching their limit. +#[derive(Clone)] +pub struct InjectorDutyCycleAnalyzer { + /// Injector pulse width channel (milliseconds) + pub pulse_width_channel: String, + /// RPM channel + pub rpm_channel: String, +} + +impl Default for InjectorDutyCycleAnalyzer { + fn default() -> Self { + Self { + pulse_width_channel: "IPW".to_string(), // Injector Pulse Width + rpm_channel: "RPM".to_string(), + } + } +} + +impl Analyzer for InjectorDutyCycleAnalyzer { + fn id(&self) -> &str { + "injector_duty_cycle" + } + + fn name(&self) -> &str { + "Injector Duty Cycle" + } + + fn description(&self) -> &str { + "Calculates injector duty cycle (%) from pulse width and RPM. \ + Formula: IDC = (PW_ms × RPM) / 1200 for 4-stroke engines. \ + Warning issued above 80% (traditional) or 95% (high-performance)." + } + + fn category(&self) -> &str { + "Derived" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.pulse_width_channel, &self.rpm_channel] + } + + fn analyze(&self, log: &Log) -> Result { + let pw = require_channel(log, &self.pulse_width_channel)?; + let rpm = require_channel(log, &self.rpm_channel)?; + + if pw.len() != rpm.len() { + return Err(AnalysisError::ComputationError( + "Channels have different lengths".to_string(), + )); + } + + require_min_length(&pw, 2)?; + + let (idc_values, computation_time) = + timed_analyze(|| compute_injector_duty_cycle(&pw, &rpm)); + + // Compute statistics + let stats = super::statistics::compute_descriptive_stats(&idc_values); + + let mut warnings = vec![]; + + // Count samples at various duty cycle thresholds + let above_80 = idc_values.iter().filter(|&&v| v > 80.0).count(); + let above_95 = idc_values.iter().filter(|&&v| v > 95.0).count(); + let at_100 = idc_values.iter().filter(|&&v| v >= 100.0).count(); + let total = idc_values.len(); + + if at_100 > 0 { + warnings.push(format!( + "CRITICAL: Injectors at 100% duty cycle ({:.1}% of time) - \ + fueling capacity exceeded, engine running lean!", + 100.0 * at_100 as f64 / total as f64 + )); + } else if above_95 > 0 { + warnings.push(format!( + "High duty cycle (>95%) detected ({:.1}% of time) - \ + approaching injector limits", + 100.0 * above_95 as f64 / total as f64 + )); + } else if above_80 > total / 10 { + warnings.push(format!( + "Elevated duty cycle (>80%) for {:.1}% of samples - \ + consider larger injectors for additional power", + 100.0 * above_80 as f64 / total as f64 + )); + } + + Ok(AnalysisResult { + name: "Injector Duty Cycle".to_string(), + unit: "%".to_string(), + values: idc_values, + metadata: AnalysisMetadata { + algorithm: "PW × RPM / 1200".to_string(), + parameters: vec![ + ("mean_idc".to_string(), format!("{:.1}%", stats.mean)), + ("max_idc".to_string(), format!("{:.1}%", stats.max)), + ("samples_above_80".to_string(), format!("{}", above_80)), + ("samples_above_95".to_string(), format!("{}", above_95)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert( + "pulse_width_channel".to_string(), + self.pulse_width_channel.clone(), + ); + params.insert("rpm_channel".to_string(), self.rpm_channel.clone()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("pulse_width_channel") { + self.pulse_width_channel = ch.clone(); + } + if let Some(ch) = config.parameters.get("rpm_channel") { + self.rpm_channel = ch.clone(); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Lambda Calculator +/// +/// Converts AFR to Lambda for easier analysis across different fuels. +#[derive(Clone)] +pub struct LambdaCalculator { + /// AFR channel to convert + pub afr_channel: String, + /// Stoichiometric AFR for the fuel (14.7 for gasoline, 14.6 for E10, etc.) + pub stoich_afr: f64, +} + +impl Default for LambdaCalculator { + fn default() -> Self { + Self { + afr_channel: "AFR".to_string(), + stoich_afr: 14.7, + } + } +} + +impl Analyzer for LambdaCalculator { + fn id(&self) -> &str { + "lambda_calculator" + } + + fn name(&self) -> &str { + "Lambda Calculator" + } + + fn description(&self) -> &str { + "Converts AFR to Lambda (λ = AFR / Stoich). Lambda of 1.0 = stoichiometric. \ + Useful for comparing fueling across different fuel types." + } + + fn category(&self) -> &str { + "Derived" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.afr_channel] + } + + fn analyze(&self, log: &Log) -> Result { + let afr = require_channel(log, &self.afr_channel)?; + require_min_length(&afr, 2)?; + + if self.stoich_afr <= 0.0 { + return Err(AnalysisError::InvalidParameter( + "Stoichiometric AFR must be positive".to_string(), + )); + } + + let (lambda_values, computation_time) = timed_analyze(|| { + afr.iter() + .map(|&a| a / self.stoich_afr) + .collect::>() + }); + + let stats = super::statistics::compute_descriptive_stats(&lambda_values); + + let mut warnings = vec![]; + + if stats.min < 0.7 { + warnings.push(format!( + "Very rich lambda detected (min {:.2}) - check for flooding or \ + over-fueling conditions", + stats.min + )); + } + if stats.max > 1.3 { + warnings.push(format!( + "Very lean lambda detected (max {:.2}) - risk of detonation, \ + check fueling", + stats.max + )); + } + + Ok(AnalysisResult { + name: "Lambda".to_string(), + unit: "λ".to_string(), + values: lambda_values, + metadata: AnalysisMetadata { + algorithm: "AFR / Stoich".to_string(), + parameters: vec![ + ("stoich_afr".to_string(), format!("{:.1}", self.stoich_afr)), + ("mean_lambda".to_string(), format!("{:.3}", stats.mean)), + ("min_lambda".to_string(), format!("{:.3}", stats.min)), + ("max_lambda".to_string(), format!("{:.3}", stats.max)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("afr_channel".to_string(), self.afr_channel.clone()); + params.insert("stoich_afr".to_string(), self.stoich_afr.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("afr_channel") { + self.afr_channel = ch.clone(); + } + if let Some(v) = config.parameters.get("stoich_afr") { + if let Ok(val) = v.parse() { + self.stoich_afr = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core derived calculation implementations +// ============================================================================ + +/// Compute Volumetric Efficiency from speed-density equation +/// +/// VE% = (Actual air mass / Theoretical air mass) × 100 +/// +/// For speed-density calculation: +/// VE% = (MAP × 2) / (P_ref × Disp × RPM × (T_ref/T_actual) × 1/60) × 100 +/// +/// Simplified: VE% ≈ (MAP / 101.325) × (298 / T_actual_K) × 100 +/// This gives a relative VE assuming standard conditions. +fn compute_volumetric_efficiency( + rpm: &[f64], + map: &[f64], + iat: &[f64], + _displacement_l: f64, + is_iat_kelvin: bool, +) -> Vec { + // Reference conditions + const P_REF: f64 = 101.325; // Standard pressure in kPa + const T_REF: f64 = 298.0; // Standard temperature in Kelvin (25°C) + + rpm.iter() + .zip(map.iter()) + .zip(iat.iter()) + .map(|((&r, &m), &t)| { + // Convert IAT to Kelvin if needed + let t_kelvin = if is_iat_kelvin { t } else { t + 273.15 }; + + // Avoid division by zero + if r <= 0.0 || t_kelvin <= 0.0 { + return 0.0; + } + + // Speed-density VE calculation + // This is a simplified model that gives relative VE + // VE = (MAP / P_ref) × (T_ref / T_actual) × 100 + // This gives how much air we're getting compared to standard conditions + + // More accurate would use MAF if available: + // VE = (MAF_actual × 120) / (ρ_std × Disp_cc × RPM) × 100 + + // For now, use the MAP-based estimate + let ve = (m / P_REF) * (T_REF / t_kelvin) * 100.0; + + // Clamp to reasonable range (negative values not physical) + ve.max(0.0) + }) + .collect() +} + +/// Compute Injector Duty Cycle +/// +/// For 4-stroke engines, each injector fires once per 2 revolutions: +/// Time per injection cycle = 60,000 / (RPM / 2) = 120,000 / RPM (in ms) +/// +/// IDC% = (Pulse_Width_ms / Time_per_cycle_ms) × 100 +/// IDC% = (PW × RPM) / 120,000 × 100 +/// IDC% = (PW × RPM) / 1200 +fn compute_injector_duty_cycle(pulse_width: &[f64], rpm: &[f64]) -> Vec { + pulse_width + .iter() + .zip(rpm.iter()) + .map(|(&pw, &r)| { + if r <= 0.0 { + return 0.0; + } + // IDC = (PW_ms × RPM) / 1200 + let idc = (pw * r) / 1200.0; + idc.clamp(0.0, 100.0) // Clamp to 0-100% + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_injector_duty_cycle() { + // At 6000 RPM with 10ms pulse width + // IDC = (10 × 6000) / 1200 = 50% + let pw = vec![10.0]; + let rpm = vec![6000.0]; + let idc = compute_injector_duty_cycle(&pw, &rpm); + assert!((idc[0] - 50.0).abs() < 0.01); + + // At 7200 RPM with 10ms pulse width + // IDC = (10 × 7200) / 1200 = 60% + let pw = vec![10.0]; + let rpm = vec![7200.0]; + let idc = compute_injector_duty_cycle(&pw, &rpm); + assert!((idc[0] - 60.0).abs() < 0.01); + + // At redline 8000 RPM with 15ms + // IDC = (15 × 8000) / 1200 = 100% + let pw = vec![15.0]; + let rpm = vec![8000.0]; + let idc = compute_injector_duty_cycle(&pw, &rpm); + assert!((idc[0] - 100.0).abs() < 0.01); + } + + #[test] + fn test_volumetric_efficiency() { + // At atmospheric pressure and standard temp, VE should be ~100% + let rpm = vec![3000.0]; + let map = vec![101.325]; // 1 atm + let iat = vec![25.0]; // 25°C + + let ve = compute_volumetric_efficiency(&rpm, &map, &iat, 2.0, false); + assert!((ve[0] - 100.0).abs() < 1.0); // Should be close to 100% + + // At half atmospheric pressure, VE should be ~50% + let map = vec![50.0]; + let ve = compute_volumetric_efficiency(&rpm, &map, &iat, 2.0, false); + assert!((ve[0] - 50.0).abs() < 5.0); + } + + #[test] + fn test_lambda_calculation() { + // Lambda = AFR / 14.7 + assert!((14.7_f64 / 14.7 - 1.0).abs() < 0.001); // Stoich = lambda 1.0 + assert!((13.0_f64 / 14.7) < 1.0); // Rich (lambda < 1) + assert!((16.0_f64 / 14.7) > 1.0); // Lean (lambda > 1) + } +} diff --git a/src/analysis/filters.rs b/src/analysis/filters.rs new file mode 100644 index 0000000..8df618d --- /dev/null +++ b/src/analysis/filters.rs @@ -0,0 +1,1235 @@ +//! Filter-based analysis algorithms. +//! +//! Provides signal processing filters for smoothing, noise reduction, +//! and data conditioning. + +use super::*; +use std::collections::{HashMap, VecDeque}; + +/// Moving Average filter analyzer +#[derive(Clone)] +pub struct MovingAverageAnalyzer { + /// Channel to filter + pub channel: String, + /// Window size for averaging + pub window_size: usize, +} + +impl Default for MovingAverageAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + window_size: 5, + } + } +} + +impl Analyzer for MovingAverageAnalyzer { + fn id(&self) -> &str { + "moving_average" + } + + fn name(&self) -> &str { + "Moving Average" + } + + fn description(&self) -> &str { + "Simple moving average filter for smoothing noisy signals. \ + Averages the last N samples to reduce high-frequency noise." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, self.window_size)?; + + let (values, computation_time) = timed_analyze(|| moving_average(&data, self.window_size)); + + Ok(AnalysisResult { + name: format!("{} (MA{})", self.channel, self.window_size), + unit: String::new(), // Same unit as input + values, + metadata: AnalysisMetadata { + algorithm: "Simple Moving Average".to_string(), + parameters: vec![ + ("window_size".to_string(), self.window_size.to_string()), + ("channel".to_string(), self.channel.clone()), + ], + warnings: vec![], + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("window_size".to_string(), self.window_size.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(ws) = config.parameters.get("window_size") { + if let Ok(size) = ws.parse() { + self.window_size = size; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Exponential Moving Average filter analyzer +#[derive(Clone)] +pub struct ExponentialMovingAverageAnalyzer { + /// Channel to filter + pub channel: String, + /// Smoothing factor alpha (0 < alpha <= 1) + /// Higher alpha = more weight to recent values + pub alpha: f64, +} + +impl Default for ExponentialMovingAverageAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + alpha: 0.2, // Equivalent to ~9 period SMA + } + } +} + +impl Analyzer for ExponentialMovingAverageAnalyzer { + fn id(&self) -> &str { + "exponential_moving_average" + } + + fn name(&self) -> &str { + "Exponential Moving Average" + } + + fn description(&self) -> &str { + "Exponentially weighted moving average filter. More recent samples have \ + higher weight. Alpha parameter controls smoothing (0.1=heavy, 0.5=light)." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + if self.alpha <= 0.0 || self.alpha > 1.0 { + return Err(AnalysisError::InvalidParameter( + "Alpha must be between 0 and 1".to_string(), + )); + } + + let (values, computation_time) = + timed_analyze(|| exponential_moving_average(&data, self.alpha)); + + Ok(AnalysisResult { + name: format!("{} (EMA α={:.2})", self.channel, self.alpha), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Exponential Moving Average".to_string(), + parameters: vec![ + ("alpha".to_string(), format!("{:.3}", self.alpha)), + ("channel".to_string(), self.channel.clone()), + ], + warnings: vec![], + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("alpha".to_string(), self.alpha.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(a) = config.parameters.get("alpha") { + if let Ok(alpha) = a.parse() { + self.alpha = alpha; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Median filter analyzer +#[derive(Clone)] +pub struct MedianFilterAnalyzer { + /// Channel to filter + pub channel: String, + /// Window size (must be odd for symmetric window) + pub window_size: usize, +} + +impl Default for MedianFilterAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + window_size: 5, + } + } +} + +impl Analyzer for MedianFilterAnalyzer { + fn id(&self) -> &str { + "median_filter" + } + + fn name(&self) -> &str { + "Median Filter" + } + + fn description(&self) -> &str { + "Median filter for removing impulse noise (spikes). Replaces each value \ + with the median of neighboring samples. Preserves edges better than averaging." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, self.window_size)?; + + // Ensure odd window size + #[allow(clippy::manual_is_multiple_of)] + let window = if self.window_size % 2 == 0 { + self.window_size + 1 + } else { + self.window_size + }; + + let (values, computation_time) = timed_analyze(|| median_filter(&data, window)); + + let mut warnings = vec![]; + #[allow(clippy::manual_is_multiple_of)] + if self.window_size % 2 == 0 { + warnings.push(format!( + "Window size adjusted from {} to {} (must be odd)", + self.window_size, window + )); + } + + Ok(AnalysisResult { + name: format!("{} (Median{})", self.channel, window), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Median Filter".to_string(), + parameters: vec![ + ("window_size".to_string(), window.to_string()), + ("channel".to_string(), self.channel.clone()), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("window_size".to_string(), self.window_size.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(ws) = config.parameters.get("window_size") { + if let Ok(size) = ws.parse() { + self.window_size = size; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core filter implementations +// ============================================================================ + +/// Simple moving average filter +pub fn moving_average(data: &[f64], window_size: usize) -> Vec { + if data.is_empty() || window_size == 0 { + return data.to_vec(); + } + + let mut result = Vec::with_capacity(data.len()); + let mut sum: f64 = 0.0; + let mut window: VecDeque = VecDeque::with_capacity(window_size); + + for &value in data { + window.push_back(value); + sum += value; + + if window.len() > window_size { + sum -= window.pop_front().unwrap(); + } + + result.push(sum / window.len() as f64); + } + + result +} + +/// Exponential moving average filter +pub fn exponential_moving_average(data: &[f64], alpha: f64) -> Vec { + if data.is_empty() { + return vec![]; + } + + let mut result = Vec::with_capacity(data.len()); + let mut ema = data[0]; + + for &value in data { + ema = alpha * value + (1.0 - alpha) * ema; + result.push(ema); + } + + result +} + +/// Median filter +pub fn median_filter(data: &[f64], window_size: usize) -> Vec { + if data.is_empty() || window_size == 0 { + return data.to_vec(); + } + + let half_window = window_size / 2; + let mut result = Vec::with_capacity(data.len()); + + for i in 0..data.len() { + let start = i.saturating_sub(half_window); + let end = (i + half_window + 1).min(data.len()); + + let mut window: Vec = data[start..end].to_vec(); + window.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + #[allow(clippy::manual_is_multiple_of)] + let median = if window.len() % 2 == 0 { + (window[window.len() / 2 - 1] + window[window.len() / 2]) / 2.0 + } else { + window[window.len() / 2] + }; + + result.push(median); + } + + result +} + +/// Butterworth lowpass filter analyzer +/// +/// Implements a digital Butterworth lowpass filter with configurable +/// cutoff frequency and filter order. Uses forward-backward filtering +/// for zero phase distortion. +#[derive(Clone)] +pub struct ButterworthLowpassAnalyzer { + /// Channel to filter + pub channel: String, + /// Cutoff frequency as fraction of sample rate (0.0 to 0.5) + /// e.g., 0.1 = cutoff at 10% of Nyquist frequency + pub cutoff_normalized: f64, + /// Filter order (1-4 recommended) + pub order: usize, +} + +impl Default for ButterworthLowpassAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + cutoff_normalized: 0.1, // 10% of Nyquist + order: 2, + } + } +} + +impl Analyzer for ButterworthLowpassAnalyzer { + fn id(&self) -> &str { + "butterworth_lowpass" + } + + fn name(&self) -> &str { + "Butterworth Lowpass" + } + + fn description(&self) -> &str { + "Butterworth lowpass filter with maximally flat passband response. \ + Uses zero-phase filtering (filtfilt) to eliminate phase distortion. \ + Cutoff is normalized (0-0.5, where 0.5 = Nyquist frequency)." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 10)?; + + if self.cutoff_normalized <= 0.0 || self.cutoff_normalized >= 0.5 { + return Err(AnalysisError::InvalidParameter( + "Cutoff must be between 0 and 0.5 (Nyquist)".to_string(), + )); + } + + if self.order < 1 || self.order > 8 { + return Err(AnalysisError::InvalidParameter( + "Order must be between 1 and 8".to_string(), + )); + } + + let (values, computation_time) = timed_analyze(|| { + butterworth_lowpass_filtfilt(&data, self.cutoff_normalized, self.order) + }); + + let mut warnings = vec![]; + if self.order > 4 { + warnings.push("High filter orders (>4) may cause numerical instability".to_string()); + } + + // Estimate actual cutoff frequency if we know sample rate + let times = log.times(); + let sample_rate_hint = if times.len() >= 2 { + 1.0 / (times[1] - times[0]).max(0.001) + } else { + 0.0 + }; + + let mut params = vec![ + ( + "cutoff_normalized".to_string(), + format!("{:.3}", self.cutoff_normalized), + ), + ("order".to_string(), self.order.to_string()), + ("channel".to_string(), self.channel.clone()), + ]; + + if sample_rate_hint > 0.0 { + let cutoff_hz = self.cutoff_normalized * sample_rate_hint; + params.push(( + "cutoff_hz_approx".to_string(), + format!("{:.1} Hz", cutoff_hz), + )); + } + + Ok(AnalysisResult { + name: format!("{} (Butter{})", self.channel, self.order), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Butterworth Lowpass (filtfilt)".to_string(), + parameters: params, + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert( + "cutoff_normalized".to_string(), + self.cutoff_normalized.to_string(), + ); + params.insert("order".to_string(), self.order.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(c) = config.parameters.get("cutoff_normalized") { + if let Ok(cutoff) = c.parse() { + self.cutoff_normalized = cutoff; + } + } + if let Some(o) = config.parameters.get("order") { + if let Ok(order) = o.parse() { + self.order = order; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Butterworth highpass filter analyzer +#[derive(Clone)] +pub struct ButterworthHighpassAnalyzer { + /// Channel to filter + pub channel: String, + /// Cutoff frequency as fraction of sample rate (0.0 to 0.5) + pub cutoff_normalized: f64, + /// Filter order (1-4 recommended) + pub order: usize, +} + +impl Default for ButterworthHighpassAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + cutoff_normalized: 0.05, // 5% of Nyquist + order: 2, + } + } +} + +impl Analyzer for ButterworthHighpassAnalyzer { + fn id(&self) -> &str { + "butterworth_highpass" + } + + fn name(&self) -> &str { + "Butterworth Highpass" + } + + fn description(&self) -> &str { + "Butterworth highpass filter for removing low-frequency drift and DC offset. \ + Uses zero-phase filtering (filtfilt) to eliminate phase distortion." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 10)?; + + if self.cutoff_normalized <= 0.0 || self.cutoff_normalized >= 0.5 { + return Err(AnalysisError::InvalidParameter( + "Cutoff must be between 0 and 0.5 (Nyquist)".to_string(), + )); + } + + if self.order < 1 || self.order > 8 { + return Err(AnalysisError::InvalidParameter( + "Order must be between 1 and 8".to_string(), + )); + } + + let (values, computation_time) = timed_analyze(|| { + butterworth_highpass_filtfilt(&data, self.cutoff_normalized, self.order) + }); + + Ok(AnalysisResult { + name: format!("{} (HP{})", self.channel, self.order), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Butterworth Highpass (filtfilt)".to_string(), + parameters: vec![ + ( + "cutoff_normalized".to_string(), + format!("{:.3}", self.cutoff_normalized), + ), + ("order".to_string(), self.order.to_string()), + ("channel".to_string(), self.channel.clone()), + ], + warnings: vec![], + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert( + "cutoff_normalized".to_string(), + self.cutoff_normalized.to_string(), + ); + params.insert("order".to_string(), self.order.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(c) = config.parameters.get("cutoff_normalized") { + if let Ok(cutoff) = c.parse() { + self.cutoff_normalized = cutoff; + } + } + if let Some(o) = config.parameters.get("order") { + if let Ok(order) = o.parse() { + self.order = order; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Butterworth filter implementation (Professional-grade) +// +// This implementation uses: +// - Second-Order Sections (SOS) for numerical stability +// - Proper bilinear transform with frequency pre-warping +// - Correct pole placement from Butterworth prototype +// - Edge padding with reflection to reduce transient artifacts +// - Steady-state initial conditions (lfilter_zi equivalent) +// - Support for orders 1-8 +// ============================================================================ + +use std::f64::consts::PI; + +/// A second-order section (biquad) filter +/// Transfer function: H(z) = (b0 + b1*z^-1 + b2*z^-2) / (1 + a1*z^-1 + a2*z^-2) +#[derive(Clone, Debug)] +struct Sos { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, +} + +impl Sos { + /// Apply this biquad section to data using Direct Form II Transposed + /// This form has better numerical properties than Direct Form I + fn filter(&self, data: &[f64], zi: Option<[f64; 2]>) -> (Vec, [f64; 2]) { + let n = data.len(); + let mut output = Vec::with_capacity(n); + + // State variables (delay elements) + let mut z1 = zi.map(|z| z[0]).unwrap_or(0.0); + let mut z2 = zi.map(|z| z[1]).unwrap_or(0.0); + + for &x in data { + // Direct Form II Transposed + let y = self.b0 * x + z1; + z1 = self.b1 * x - self.a1 * y + z2; + z2 = self.b2 * x - self.a2 * y; + output.push(y); + } + + (output, [z1, z2]) + } + + /// Compute steady-state initial conditions for step response + /// This is equivalent to scipy's lfilter_zi + fn compute_zi(&self, x0: f64) -> [f64; 2] { + // For a step input of value x0, compute the initial state + // that would make the output also be x0 (steady state) + // + // From: y[n] = b0*x[n] + z1[n-1] + // z1[n] = b1*x[n] - a1*y[n] + z2[n-1] + // z2[n] = b2*x[n] - a2*y[n] + // + // At steady state with x[n] = y[n] = x0: + // z2 = (b2 - a2) * x0 + // z1 = (b1 - a1) * x0 + z2 = (b1 - a1 + b2 - a2) * x0 + + let z2 = (self.b2 - self.a2) * x0; + let z1 = (self.b1 - self.a1) * x0 + z2; + [z1, z2] + } +} + +/// Generate Butterworth lowpass second-order sections +/// +/// Uses the standard approach: +/// 1. Compute analog prototype poles on unit circle +/// 2. Apply bilinear transform with frequency pre-warping +/// 3. Return cascaded biquad sections +fn butterworth_lowpass_sos(cutoff: f64, order: usize) -> Vec { + if order == 0 { + return vec![]; + } + + // Pre-warp the cutoff frequency for bilinear transform + // wc = tan(pi * cutoff) where cutoff is normalized frequency (0 to 0.5) + let wc = (PI * cutoff).tan(); + + let mut sections = Vec::new(); + + // For odd orders, we have one first-order section + if order % 2 == 1 { + // First-order section from real pole at s = -1 + // Bilinear transform: s = (1 - z^-1) / (1 + z^-1) * (1/wc) + // H(s) = wc / (s + wc) -> H(z) + + let k = wc / (1.0 + wc); + sections.push(Sos { + b0: k, + b1: k, + b2: 0.0, + a1: (wc - 1.0) / (wc + 1.0), + a2: 0.0, + }); + } + + // Second-order sections from complex conjugate pole pairs + let num_biquads = order / 2; + for i in 0..num_biquads { + // Butterworth poles are evenly spaced on the left half of the unit circle + // For order n, pole angles are: theta_k = pi * (2k + n + 1) / (2n) + // For the k-th biquad (0-indexed), we use poles k and (n-1-k) + + // Pole angle for this biquad section + // Using: theta = pi * (2*i + 1 + (order % 2)) / (2 * order) + pi/2 + let k_index = i as f64; + let theta = PI * (2.0 * k_index + 1.0 + (order % 2) as f64) / (2.0 * order as f64); + + // Analog prototype pole: p = -sin(theta) + j*cos(theta) (on unit circle) + // For Butterworth, the damping factor relates to theta + // Q = 1 / (2 * cos(theta)) but we use the direct pole representation + + // The second-order analog section is: + // H(s) = wc^2 / (s^2 + 2*zeta*wc*s + wc^2) + // where zeta = sin(theta) = cos(pi/2 - theta) + + let zeta = theta.sin(); // damping ratio for this section + + // Bilinear transform of second-order lowpass section + // Using s = (2/T) * (1 - z^-1) / (1 + z^-1), with T = 2 (normalized) + // and pre-warped wc + + let wc2 = wc * wc; + let two_zeta_wc = 2.0 * zeta * wc; + + // Denominator: s^2 + 2*zeta*wc*s + wc^2 + // After bilinear: (1 + a1*z^-1 + a2*z^-2) * norm + let denom = 1.0 + two_zeta_wc + wc2; + + let a1 = 2.0 * (wc2 - 1.0) / denom; + let a2 = (1.0 - two_zeta_wc + wc2) / denom; + + // Numerator: wc^2 + // After bilinear: (b0 + b1*z^-1 + b2*z^-2) + let b0 = wc2 / denom; + let b1 = 2.0 * wc2 / denom; + let b2 = wc2 / denom; + + sections.push(Sos { b0, b1, b2, a1, a2 }); + } + + sections +} + +/// Generate Butterworth highpass second-order sections +/// +/// Uses the lowpass-to-highpass transformation in the analog domain: +/// s -> wc/s, then applies bilinear transform +fn butterworth_highpass_sos(cutoff: f64, order: usize) -> Vec { + if order == 0 { + return vec![]; + } + + // Pre-warp the cutoff frequency + let wc = (PI * cutoff).tan(); + + let mut sections = Vec::new(); + + // For odd orders, we have one first-order section + if order % 2 == 1 { + // First-order highpass: H(s) = s / (s + wc) + // After bilinear transform: + let k = 1.0 / (1.0 + wc); + sections.push(Sos { + b0: k, + b1: -k, + b2: 0.0, + a1: (wc - 1.0) / (wc + 1.0), + a2: 0.0, + }); + } + + // Second-order sections + let num_biquads = order / 2; + for i in 0..num_biquads { + let k_index = i as f64; + let theta = PI * (2.0 * k_index + 1.0 + (order % 2) as f64) / (2.0 * order as f64); + let zeta = theta.sin(); + + // Highpass second-order section: H(s) = s^2 / (s^2 + 2*zeta*wc*s + wc^2) + // After bilinear transform: + + let wc2 = wc * wc; + let two_zeta_wc = 2.0 * zeta * wc; + let denom = 1.0 + two_zeta_wc + wc2; + + let a1 = 2.0 * (wc2 - 1.0) / denom; + let a2 = (1.0 - two_zeta_wc + wc2) / denom; + + // Numerator for highpass: s^2 -> (1 - z^-1)^2 / (1 + z^-1)^2 after bilinear + // Normalized: (1 - 2*z^-1 + z^-2) / denom + let norm = 1.0 / denom; + let b0 = norm; + let b1 = -2.0 * norm; + let b2 = norm; + + sections.push(Sos { b0, b1, b2, a1, a2 }); + } + + sections +} + +/// Apply a cascade of SOS sections to data +#[allow(dead_code)] +fn sos_filter(data: &[f64], sos: &[Sos], zi: Option<&[[f64; 2]]>) -> Vec { + if data.is_empty() || sos.is_empty() { + return data.to_vec(); + } + + let mut result = data.to_vec(); + + for (i, section) in sos.iter().enumerate() { + let initial = zi.and_then(|z| z.get(i).copied()); + let (filtered, _) = section.filter(&result, initial); + result = filtered; + } + + result +} + +/// Compute initial conditions for steady-state filtering +fn sos_compute_zi(sos: &[Sos], x0: f64) -> Vec<[f64; 2]> { + sos.iter().map(|s| s.compute_zi(x0)).collect() +} + +/// Reflect-pad the signal to reduce edge transients +/// Pads with reflected values at both ends +fn reflect_pad(data: &[f64], pad_len: usize) -> Vec { + if data.len() < 2 { + return data.to_vec(); + } + + let n = data.len(); + let pad_len = pad_len.min(n - 1); // Can't pad more than data length - 1 + + let mut padded = Vec::with_capacity(n + 2 * pad_len); + + // Left padding: reflect about first element + // data[0] - (data[pad_len] - data[0]), data[0] - (data[pad_len-1] - data[0]), ... + for i in (1..=pad_len).rev() { + padded.push(2.0 * data[0] - data[i]); + } + + // Original data + padded.extend_from_slice(data); + + // Right padding: reflect about last element + for i in 1..=pad_len { + let idx = n - 1 - i; + padded.push(2.0 * data[n - 1] - data[idx]); + } + + padded +} + +/// Zero-phase filtering using forward-backward filtering with edge padding +/// This is equivalent to scipy's filtfilt +fn sosfiltfilt(data: &[f64], sos: &[Sos]) -> Vec { + if data.is_empty() || sos.is_empty() { + return data.to_vec(); + } + + // Check for NaN/Inf in input + if data.iter().any(|&x| !x.is_finite()) { + // Replace non-finite values with interpolated values or zeros + let cleaned: Vec = data + .iter() + .map(|&x| if x.is_finite() { x } else { 0.0 }) + .collect(); + return sosfiltfilt(&cleaned, sos); + } + + let n = data.len(); + + // Padding length: 3 * max(len(a), len(b)) per scipy, which is 3*3 = 9 per section + // For cascaded sections, use 3 * order + let pad_len = (3 * sos.len() * 2).min(n - 1).max(1); + + // Pad the signal + let padded = reflect_pad(data, pad_len); + + // Compute initial conditions based on the padded edge value + let zi_forward = sos_compute_zi(sos, padded[0]); + + // Forward pass with initial conditions + let mut forward = padded.clone(); + for (i, section) in sos.iter().enumerate() { + let (filtered, _) = section.filter(&forward, Some(zi_forward[i])); + forward = filtered; + } + + // Reverse + forward.reverse(); + + // Compute initial conditions for backward pass + let zi_backward = sos_compute_zi(sos, forward[0]); + + // Backward pass with initial conditions + let mut backward = forward; + for (i, section) in sos.iter().enumerate() { + let (filtered, _) = section.filter(&backward, Some(zi_backward[i])); + backward = filtered; + } + + // Reverse back + backward.reverse(); + + // Remove padding + backward[pad_len..pad_len + n].to_vec() +} + +/// Butterworth lowpass filter with zero-phase filtering +/// +/// Uses second-order sections for numerical stability and +/// forward-backward filtering to eliminate phase distortion. +/// +/// # Arguments +/// * `data` - Input signal +/// * `cutoff` - Normalized cutoff frequency (0 < cutoff < 0.5, where 0.5 = Nyquist) +/// * `order` - Filter order (1-8) +/// +/// # Returns +/// Filtered signal with same length as input +pub fn butterworth_lowpass_filtfilt(data: &[f64], cutoff: f64, order: usize) -> Vec { + if data.is_empty() { + return vec![]; + } + + // Clamp order to valid range + let order = order.clamp(1, 8); + + // Clamp cutoff to valid range (with small margin from boundaries) + let cutoff = cutoff.clamp(0.001, 0.499); + + let sos = butterworth_lowpass_sos(cutoff, order); + sosfiltfilt(data, &sos) +} + +/// Butterworth highpass filter with zero-phase filtering +/// +/// Uses second-order sections for numerical stability and +/// forward-backward filtering to eliminate phase distortion. +/// +/// # Arguments +/// * `data` - Input signal +/// * `cutoff` - Normalized cutoff frequency (0 < cutoff < 0.5, where 0.5 = Nyquist) +/// * `order` - Filter order (1-8) +/// +/// # Returns +/// Filtered signal with same length as input +pub fn butterworth_highpass_filtfilt(data: &[f64], cutoff: f64, order: usize) -> Vec { + if data.is_empty() { + return vec![]; + } + + // Clamp order to valid range + let order = order.clamp(1, 8); + + // Clamp cutoff to valid range + let cutoff = cutoff.clamp(0.001, 0.499); + + let sos = butterworth_highpass_sos(cutoff, order); + sosfiltfilt(data, &sos) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_moving_average() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = moving_average(&data, 3); + + assert_eq!(result.len(), 5); + assert!((result[0] - 1.0).abs() < 0.001); // First value is just itself + assert!((result[1] - 1.5).abs() < 0.001); // (1+2)/2 + assert!((result[2] - 2.0).abs() < 0.001); // (1+2+3)/3 + assert!((result[3] - 3.0).abs() < 0.001); // (2+3+4)/3 + assert!((result[4] - 4.0).abs() < 0.001); // (3+4+5)/3 + } + + #[test] + fn test_exponential_moving_average() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = exponential_moving_average(&data, 0.5); + + assert_eq!(result.len(), 5); + assert!((result[0] - 1.0).abs() < 0.001); + // EMA grows towards recent values + assert!(result[4] > result[0]); + } + + #[test] + fn test_median_filter() { + // Test with spike + let data = vec![1.0, 1.0, 100.0, 1.0, 1.0]; + let result = median_filter(&data, 3); + + assert_eq!(result.len(), 5); + // The spike should be removed + assert!((result[2] - 1.0).abs() < 0.001); + } + + #[test] + fn test_butterworth_lowpass_removes_high_freq() { + // Create a signal with low frequency + high frequency noise + let data: Vec = (0..200) + .map(|i| { + let t = i as f64 * 0.01; + // 1 Hz signal + 10 Hz noise (at 100 Hz sample rate) + (2.0 * PI * 1.0 * t).sin() + 0.5 * (2.0 * PI * 10.0 * t).sin() + }) + .collect(); + + // Apply lowpass filter with cutoff at 0.05 (5 Hz at 100 Hz sample rate) + let filtered = butterworth_lowpass_filtfilt(&data, 0.05, 4); + + assert_eq!(filtered.len(), data.len()); + + // The filtered signal should have reduced high frequency content + let orig_power: f64 = data.iter().map(|x| x * x).sum::(); + let filt_power: f64 = filtered.iter().map(|x| x * x).sum::(); + + // Filtered should have less power due to noise removal + assert!(filt_power < orig_power); + + // But should retain most of the low-frequency signal + assert!(filt_power > orig_power * 0.3); + } + + #[test] + fn test_butterworth_preserves_dc() { + // DC signal should pass through lowpass unchanged + let data = vec![5.0; 200]; + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 4); + + // All values should be very close to 5.0 + for &v in &filtered { + assert!((v - 5.0).abs() < 0.01, "DC not preserved: got {}", v); + } + } + + #[test] + fn test_butterworth_highpass_removes_dc() { + // DC + AC signal + let data: Vec = (0..200) + .map(|i| { + let t = i as f64 * 0.01; + 10.0 + (2.0 * PI * 5.0 * t).sin() // DC offset + 5 Hz signal + }) + .collect(); + + // Highpass with cutoff at 0.02 (2 Hz at 100 Hz sample rate) + let filtered = butterworth_highpass_filtfilt(&data, 0.02, 2); + + assert_eq!(filtered.len(), data.len()); + + // Mean should be close to zero (DC removed) + let mean: f64 = filtered.iter().sum::() / filtered.len() as f64; + assert!(mean.abs() < 1.0, "DC not removed: mean = {}", mean); + } + + #[test] + fn test_butterworth_all_orders() { + // Test that all orders 1-8 work without panicking + let data: Vec = (0..100).map(|i| (i as f64 * 0.1).sin()).collect(); + + for order in 1..=8 { + let filtered = butterworth_lowpass_filtfilt(&data, 0.2, order); + assert_eq!(filtered.len(), data.len(), "Order {} failed", order); + + // Verify no NaN values + assert!( + !filtered.iter().any(|x| x.is_nan()), + "Order {} produced NaN", + order + ); + } + } + + #[test] + fn test_butterworth_handles_nan() { + // Data with NaN values should be handled gracefully + let mut data: Vec = (0..100).map(|i| i as f64).collect(); + data[50] = f64::NAN; + + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 2); + + assert_eq!(filtered.len(), data.len()); + assert!(!filtered.iter().any(|x| x.is_nan()), "Output contains NaN"); + } + + #[test] + fn test_butterworth_edge_transients() { + // Step function - should have minimal edge transients with proper padding + let mut data = vec![0.0; 100]; + data.extend(vec![1.0; 100]); + + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 2); + + // First few samples should be close to 0 (not wildly oscillating) + assert!(filtered[0].abs() < 0.2, "Edge transient too large at start"); + + // Last few samples should be close to 1 + assert!( + (filtered[filtered.len() - 1] - 1.0).abs() < 0.2, + "Edge transient too large at end" + ); + } + + #[test] + fn test_reflect_pad() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let padded = reflect_pad(&data, 2); + + // Should be: [2*1-3, 2*1-2, 1, 2, 3, 4, 5, 2*5-4, 2*5-3] + // = [-1, 0, 1, 2, 3, 4, 5, 6, 7] + assert_eq!(padded.len(), 9); + assert!((padded[0] - (-1.0)).abs() < 0.001); + assert!((padded[1] - 0.0).abs() < 0.001); + assert!((padded[2] - 1.0).abs() < 0.001); + assert!((padded[6] - 5.0).abs() < 0.001); + assert!((padded[7] - 6.0).abs() < 0.001); + assert!((padded[8] - 7.0).abs() < 0.001); + } + + #[test] + fn test_sos_lowpass_coefficients() { + // Verify that the SOS coefficients are reasonable + let sos = butterworth_lowpass_sos(0.2, 2); + + assert_eq!(sos.len(), 1); // Order 2 = 1 biquad + + // DC gain should be 1 (sum of b coeffs / sum of a coeffs) + let section = &sos[0]; + let b_sum = section.b0 + section.b1 + section.b2; + let a_sum = 1.0 + section.a1 + section.a2; + let dc_gain = b_sum / a_sum; + + assert!( + (dc_gain - 1.0).abs() < 0.01, + "DC gain should be 1, got {}", + dc_gain + ); + } + + #[test] + fn test_sos_highpass_coefficients() { + // Verify highpass has zero DC gain + let sos = butterworth_highpass_sos(0.2, 2); + + assert_eq!(sos.len(), 1); + + // DC gain should be 0 for highpass + let section = &sos[0]; + let b_sum = section.b0 + section.b1 + section.b2; + + assert!(b_sum.abs() < 0.01, "HP DC gain should be 0, got {}", b_sum); + } + + #[test] + fn test_butterworth_symmetry() { + // Zero-phase filtering should be symmetric around center + let mut data = vec![0.0; 50]; + data.push(1.0); // Impulse at center + data.extend(vec![0.0; 49]); + + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 2); + + // Response should be symmetric around the impulse + let center = 50; + for i in 1..20 { + let left = filtered[center - i]; + let right = filtered[center + i]; + assert!( + (left - right).abs() < 0.01, + "Not symmetric at offset {}: left={}, right={}", + i, + left, + right + ); + } + } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs new file mode 100644 index 0000000..17de11e --- /dev/null +++ b/src/analysis/mod.rs @@ -0,0 +1,346 @@ +//! Analysis module for ECU log analysis algorithms. +//! +//! This module provides a unified framework for implementing analysis algorithms +//! that can process log data and produce results that integrate with UltraLog's +//! computed channels system. +//! +//! The architecture follows a trait-based design where each analyzer implements +//! the `Analyzer` trait, enabling: +//! - Dynamic discovery of available analyzers based on loaded channels +//! - Configurable parameters via UI +//! - Results that can be visualized or converted to computed channels + +pub mod afr; +pub mod derived; +pub mod filters; +pub mod statistics; + +use crate::parsers::types::Log; +use std::collections::HashMap; +use std::time::Instant; + +/// Errors that can occur during analysis +#[derive(Debug, Clone)] +pub enum AnalysisError { + /// A required channel is missing from the log data + MissingChannel(String), + /// Not enough data points for the analysis + InsufficientData { needed: usize, got: usize }, + /// Invalid parameter configuration + InvalidParameter(String), + /// General computation error + ComputationError(String), +} + +impl std::fmt::Display for AnalysisError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AnalysisError::MissingChannel(ch) => write!(f, "Missing required channel: {}", ch), + AnalysisError::InsufficientData { needed, got } => { + write!(f, "Insufficient data: need {} points, got {}", needed, got) + } + AnalysisError::InvalidParameter(msg) => write!(f, "Invalid parameter: {}", msg), + AnalysisError::ComputationError(msg) => write!(f, "Computation error: {}", msg), + } + } +} + +impl std::error::Error for AnalysisError {} + +/// Metadata about analysis results for UI display +#[derive(Clone, Debug, Default)] +pub struct AnalysisMetadata { + /// Name of the algorithm used + pub algorithm: String, + /// Key parameters and their values + pub parameters: Vec<(String, String)>, + /// Warning messages about the analysis + pub warnings: Vec, + /// Time taken for computation in milliseconds + pub computation_time_ms: u64, +} + +/// Result of an analysis operation +#[derive(Clone, Debug)] +pub struct AnalysisResult { + /// Name for the result (used as channel name if added) + pub name: String, + /// Unit for the result values + pub unit: String, + /// The computed values (one per timestamp) + pub values: Vec, + /// Metadata about the analysis + pub metadata: AnalysisMetadata, +} + +impl AnalysisResult { + /// Create a new analysis result + pub fn new(name: impl Into, unit: impl Into, values: Vec) -> Self { + Self { + name: name.into(), + unit: unit.into(), + values, + metadata: AnalysisMetadata::default(), + } + } + + /// Add metadata to the result + pub fn with_metadata(mut self, metadata: AnalysisMetadata) -> Self { + self.metadata = metadata; + self + } + + /// Check if the analysis produced any warnings + pub fn has_warnings(&self) -> bool { + !self.metadata.warnings.is_empty() + } +} + +/// Configuration for an analyzer that can be serialized +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct AnalyzerConfig { + /// Unique identifier for the analyzer + pub id: String, + /// Human-readable name + pub name: String, + /// Parameter values as key-value pairs + pub parameters: HashMap, +} + +/// Core trait for all analysis algorithms +pub trait Analyzer: Send + Sync { + /// Unique identifier for this analyzer + fn id(&self) -> &str; + + /// Human-readable algorithm name + fn name(&self) -> &str; + + /// Description for UI tooltips + fn description(&self) -> &str; + + /// Category for grouping in UI (e.g., "Filters", "Statistics", "AFR", "Knock") + fn category(&self) -> &str; + + /// List of required channel names (normalized names preferred) + fn required_channels(&self) -> Vec<&str>; + + /// Optional channels that enhance analysis if present + fn optional_channels(&self) -> Vec<&str> { + vec![] + } + + /// Execute analysis on log data + fn analyze(&self, log: &Log) -> Result; + + /// Get current configuration + fn get_config(&self) -> AnalyzerConfig; + + /// Apply configuration + fn set_config(&mut self, config: &AnalyzerConfig); + + /// Clone into a boxed trait object + fn clone_box(&self) -> Box; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + +/// Helper trait for accessing log data by channel name +pub trait LogDataAccess { + /// Get channel values by name (case-insensitive) + fn get_channel_values(&self, name: &str) -> Option>; + + /// Check if a channel exists + fn has_channel(&self, name: &str) -> bool; + + /// Get all channel names + fn channel_names(&self) -> Vec; + + /// Get the time vector + fn times(&self) -> &[f64]; +} + +impl LogDataAccess for Log { + fn get_channel_values(&self, name: &str) -> Option> { + // Find the channel index (case-insensitive) + let channel_idx = self + .channels + .iter() + .position(|c| c.name().eq_ignore_ascii_case(name))?; + + // Extract values from the data matrix + let values: Vec = self + .data + .iter() + .filter_map(|row| row.get(channel_idx).map(|v| v.as_f64())) + .collect(); + + Some(values) + } + + fn has_channel(&self, name: &str) -> bool { + self.channels + .iter() + .any(|c| c.name().eq_ignore_ascii_case(name)) + } + + fn channel_names(&self) -> Vec { + self.channels.iter().map(|c| c.name()).collect() + } + + fn times(&self) -> &[f64] { + &self.times + } +} + +/// Registry of available analyzers +#[derive(Default)] +pub struct AnalyzerRegistry { + analyzers: Vec>, +} + +impl AnalyzerRegistry { + /// Create a new registry with default analyzers + pub fn new() -> Self { + let mut registry = Self { + analyzers: Vec::new(), + }; + + // Register built-in analyzers + registry.register_defaults(); + + registry + } + + /// Register default analyzers + fn register_defaults(&mut self) { + // Filters + self.register(Box::new(filters::MovingAverageAnalyzer::default())); + self.register(Box::new( + filters::ExponentialMovingAverageAnalyzer::default(), + )); + self.register(Box::new(filters::MedianFilterAnalyzer::default())); + self.register(Box::new(filters::ButterworthLowpassAnalyzer::default())); + self.register(Box::new(filters::ButterworthHighpassAnalyzer::default())); + + // Statistics + self.register(Box::new(statistics::DescriptiveStatsAnalyzer::default())); + self.register(Box::new(statistics::CorrelationAnalyzer::default())); + self.register(Box::new(statistics::RateOfChangeAnalyzer::default())); + + // AFR Analysis + self.register(Box::new(afr::FuelTrimDriftAnalyzer::default())); + self.register(Box::new(afr::RichLeanZoneAnalyzer::default())); + self.register(Box::new(afr::AfrDeviationAnalyzer::default())); + + // Derived Calculations + self.register(Box::new(derived::VolumetricEfficiencyAnalyzer::default())); + self.register(Box::new(derived::InjectorDutyCycleAnalyzer::default())); + self.register(Box::new(derived::LambdaCalculator::default())); + } + + /// Register a new analyzer + pub fn register(&mut self, analyzer: Box) { + self.analyzers.push(analyzer); + } + + /// Get all registered analyzers + pub fn all(&self) -> &[Box] { + &self.analyzers + } + + /// Get analyzers available for the given log data + pub fn available_for(&self, log: &Log) -> Vec<&dyn Analyzer> { + self.analyzers + .iter() + .filter(|a| a.required_channels().iter().all(|ch| log.has_channel(ch))) + .map(|a| a.as_ref()) + .collect() + } + + /// Get analyzers by category + pub fn by_category(&self) -> HashMap> { + let mut categories: HashMap> = HashMap::new(); + + for analyzer in &self.analyzers { + categories + .entry(analyzer.category().to_string()) + .or_default() + .push(analyzer.as_ref()); + } + + categories + } + + /// Find an analyzer by ID + pub fn find_by_id(&self, id: &str) -> Option<&dyn Analyzer> { + self.analyzers + .iter() + .find(|a| a.id() == id) + .map(|a| a.as_ref()) + } + + /// Find an analyzer by ID and return a mutable reference + pub fn find_by_id_mut(&mut self, id: &str) -> Option<&mut Box> { + self.analyzers.iter_mut().find(|a| a.id() == id) + } +} + +/// Helper function to measure analysis execution time +pub fn timed_analyze(f: F) -> (T, u64) +where + F: FnOnce() -> T, +{ + let start = Instant::now(); + let result = f(); + let elapsed = start.elapsed().as_millis() as u64; + (result, elapsed) +} + +/// Helper to get a required channel or return an error +pub fn require_channel(log: &Log, name: &str) -> Result, AnalysisError> { + log.get_channel_values(name) + .ok_or_else(|| AnalysisError::MissingChannel(name.to_string())) +} + +/// Helper to check minimum data length +pub fn require_min_length(data: &[f64], min_len: usize) -> Result<(), AnalysisError> { + if data.len() < min_len { + Err(AnalysisError::InsufficientData { + needed: min_len, + got: data.len(), + }) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_analysis_error_display() { + let err = AnalysisError::MissingChannel("RPM".to_string()); + assert!(err.to_string().contains("RPM")); + + let err = AnalysisError::InsufficientData { + needed: 100, + got: 50, + }; + assert!(err.to_string().contains("100")); + assert!(err.to_string().contains("50")); + } + + #[test] + fn test_analysis_result_new() { + let result = AnalysisResult::new("Test", "units", vec![1.0, 2.0, 3.0]); + assert_eq!(result.name, "Test"); + assert_eq!(result.unit, "units"); + assert_eq!(result.values.len(), 3); + assert!(!result.has_warnings()); + } +} diff --git a/src/analysis/statistics.rs b/src/analysis/statistics.rs new file mode 100644 index 0000000..336c251 --- /dev/null +++ b/src/analysis/statistics.rs @@ -0,0 +1,602 @@ +//! Statistical analysis algorithms. +//! +//! Provides statistical measures, correlation analysis, and data characterization. + +use super::*; +use std::collections::HashMap; + +/// Descriptive statistics analyzer +#[derive(Clone)] +pub struct DescriptiveStatsAnalyzer { + /// Channel to analyze + pub channel: String, +} + +impl Default for DescriptiveStatsAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + } + } +} + +impl Analyzer for DescriptiveStatsAnalyzer { + fn id(&self) -> &str { + "descriptive_stats" + } + + fn name(&self) -> &str { + "Descriptive Statistics" + } + + fn description(&self) -> &str { + "Computes basic statistics: mean, median, standard deviation, min, max, \ + range, and coefficient of variation for a channel." + } + + fn category(&self) -> &str { + "Statistics" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + let (stats, computation_time) = timed_analyze(|| compute_descriptive_stats(&data)); + + let mut warnings = vec![]; + + // Warn about high coefficient of variation + if stats.cv > 50.0 { + warnings.push(format!( + "High variability detected (CV={:.1}%) - signal may be noisy", + stats.cv + )); + } + + // Create a "normalized" version of the data for visualization (z-scores) + let z_scores: Vec = data + .iter() + .map(|&x| (x - stats.mean) / stats.stdev.max(0.001)) + .collect(); + + Ok(AnalysisResult { + name: format!("{} Z-Score", self.channel), + unit: "σ".to_string(), + values: z_scores, + metadata: AnalysisMetadata { + algorithm: "Descriptive Statistics".to_string(), + parameters: vec![ + ("mean".to_string(), format!("{:.4}", stats.mean)), + ("median".to_string(), format!("{:.4}", stats.median)), + ("stdev".to_string(), format!("{:.4}", stats.stdev)), + ("min".to_string(), format!("{:.4}", stats.min)), + ("max".to_string(), format!("{:.4}", stats.max)), + ("range".to_string(), format!("{:.4}", stats.range)), + ("cv".to_string(), format!("{:.2}%", stats.cv)), + ("n".to_string(), stats.count.to_string()), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Correlation analyzer between two channels +#[derive(Clone)] +pub struct CorrelationAnalyzer { + /// First channel (X) + pub channel_x: String, + /// Second channel (Y) + pub channel_y: String, +} + +impl Default for CorrelationAnalyzer { + fn default() -> Self { + Self { + channel_x: "RPM".to_string(), + channel_y: "MAP".to_string(), + } + } +} + +impl Analyzer for CorrelationAnalyzer { + fn id(&self) -> &str { + "correlation" + } + + fn name(&self) -> &str { + "Channel Correlation" + } + + fn description(&self) -> &str { + "Computes Pearson correlation coefficient between two channels. \ + Values near ±1 indicate strong linear relationship." + } + + fn category(&self) -> &str { + "Statistics" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel_x, &self.channel_y] + } + + fn analyze(&self, log: &Log) -> Result { + let x = require_channel(log, &self.channel_x)?; + let y = require_channel(log, &self.channel_y)?; + + if x.len() != y.len() { + return Err(AnalysisError::ComputationError( + "Channels have different lengths".to_string(), + )); + } + + require_min_length(&x, 3)?; + + let (correlation, computation_time) = timed_analyze(|| pearson_correlation(&x, &y)); + + let mut warnings = vec![]; + let r = correlation.r; + + // Interpret correlation strength + let strength = if r.abs() > 0.9 { + "very strong" + } else if r.abs() > 0.7 { + "strong" + } else if r.abs() > 0.5 { + "moderate" + } else if r.abs() > 0.3 { + "weak" + } else { + "very weak/none" + }; + + let direction = if r > 0.0 { "positive" } else { "negative" }; + + warnings.push(format!( + "Correlation is {} {} (r={:.3})", + strength, direction, r + )); + + // Create residuals for visualization + let residuals = compute_residuals(&x, &y); + + Ok(AnalysisResult { + name: format!("{} vs {} Residuals", self.channel_x, self.channel_y), + unit: String::new(), + values: residuals, + metadata: AnalysisMetadata { + algorithm: "Pearson Correlation".to_string(), + parameters: vec![ + ("r".to_string(), format!("{:.4}", r)), + ("r²".to_string(), format!("{:.4}", r * r)), + ("channel_x".to_string(), self.channel_x.clone()), + ("channel_y".to_string(), self.channel_y.clone()), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel_x".to_string(), self.channel_x.clone()); + params.insert("channel_y".to_string(), self.channel_y.clone()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel_x") { + self.channel_x = ch.clone(); + } + if let Some(ch) = config.parameters.get("channel_y") { + self.channel_y = ch.clone(); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Rate of change analyzer +#[derive(Clone)] +pub struct RateOfChangeAnalyzer { + /// Channel to analyze + pub channel: String, + /// Use time-based derivative (true) or sample-based (false) + pub time_based: bool, +} + +impl Default for RateOfChangeAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + time_based: true, + } + } +} + +impl Analyzer for RateOfChangeAnalyzer { + fn id(&self) -> &str { + "rate_of_change" + } + + fn name(&self) -> &str { + "Rate of Change" + } + + fn description(&self) -> &str { + "Computes the derivative (rate of change) of a channel. Time-based mode \ + gives units per second; sample-based gives units per sample." + } + + fn category(&self) -> &str { + "Statistics" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + let times = log.times(); + if times.len() != data.len() { + return Err(AnalysisError::ComputationError( + "Data and time vectors have different lengths".to_string(), + )); + } + + let (derivative, computation_time) = timed_analyze(|| { + if self.time_based { + time_derivative(&data, times) + } else { + sample_derivative(&data) + } + }); + + // Compute statistics on the derivative + let stats = compute_descriptive_stats(&derivative); + + let mut warnings = vec![]; + + // Warn about high rate of change + let max_abs_rate = stats.max.abs().max(stats.min.abs()); + if max_abs_rate > stats.stdev * 5.0 { + warnings.push(format!( + "Extreme rate of change detected: max |dv/dt| = {:.2}", + max_abs_rate + )); + } + + let unit = if self.time_based { "/s" } else { "/sample" }; + + Ok(AnalysisResult { + name: format!("d({})/dt", self.channel), + unit: unit.to_string(), + values: derivative, + metadata: AnalysisMetadata { + algorithm: if self.time_based { + "Time-based Derivative" + } else { + "Sample-based Derivative" + } + .to_string(), + parameters: vec![ + ("channel".to_string(), self.channel.clone()), + ("mean_rate".to_string(), format!("{:.4}", stats.mean)), + ("max_rate".to_string(), format!("{:.4}", stats.max)), + ("min_rate".to_string(), format!("{:.4}", stats.min)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("time_based".to_string(), self.time_based.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(tb) = config.parameters.get("time_based") { + self.time_based = tb.parse().unwrap_or(true); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core statistics implementations +// ============================================================================ + +/// Container for descriptive statistics +#[derive(Clone, Debug, Default)] +pub struct DescriptiveStats { + pub count: usize, + pub mean: f64, + pub median: f64, + pub stdev: f64, + pub min: f64, + pub max: f64, + pub range: f64, + pub cv: f64, // Coefficient of variation (%) +} + +/// Compute descriptive statistics for a dataset +pub fn compute_descriptive_stats(data: &[f64]) -> DescriptiveStats { + if data.is_empty() { + return DescriptiveStats::default(); + } + + let n = data.len(); + + // Mean (Welford's algorithm for numerical stability) + let mean = data.iter().sum::() / n as f64; + + // Variance (two-pass for stability) + let variance = data.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1).max(1) as f64; + let stdev = variance.sqrt(); + + // Min/Max + let min = data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + // Median (requires sorting) + let mut sorted = data.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + #[allow(clippy::manual_is_multiple_of)] + let median = if n % 2 == 0 { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + } else { + sorted[n / 2] + }; + + // Coefficient of variation + let cv = if mean.abs() > f64::EPSILON { + (stdev / mean.abs()) * 100.0 + } else { + 0.0 + }; + + DescriptiveStats { + count: n, + mean, + median, + stdev, + min, + max, + range: max - min, + cv, + } +} + +/// Container for correlation results +#[derive(Clone, Debug, Default)] +pub struct CorrelationResult { + pub r: f64, // Pearson correlation coefficient + pub r_squared: f64, // Coefficient of determination +} + +/// Compute Pearson correlation coefficient +pub fn pearson_correlation(x: &[f64], y: &[f64]) -> CorrelationResult { + if x.len() != y.len() || x.len() < 2 { + return CorrelationResult::default(); + } + + let n = x.len() as f64; + let mean_x = x.iter().sum::() / n; + let mean_y = y.iter().sum::() / n; + + let mut cov = 0.0; + let mut var_x = 0.0; + let mut var_y = 0.0; + + for i in 0..x.len() { + let dx = x[i] - mean_x; + let dy = y[i] - mean_y; + cov += dx * dy; + var_x += dx * dx; + var_y += dy * dy; + } + + let denom = (var_x * var_y).sqrt(); + let r = if denom > f64::EPSILON { + cov / denom + } else { + 0.0 + }; + + CorrelationResult { + r, + r_squared: r * r, + } +} + +/// Compute residuals from linear regression +pub fn compute_residuals(x: &[f64], y: &[f64]) -> Vec { + if x.len() != y.len() || x.len() < 2 { + return vec![]; + } + + let n = x.len() as f64; + let mean_x = x.iter().sum::() / n; + let mean_y = y.iter().sum::() / n; + + // Compute slope and intercept + let mut num = 0.0; + let mut den = 0.0; + for i in 0..x.len() { + let dx = x[i] - mean_x; + num += dx * (y[i] - mean_y); + den += dx * dx; + } + + let slope = if den.abs() > f64::EPSILON { + num / den + } else { + 0.0 + }; + let intercept = mean_y - slope * mean_x; + + // Compute residuals + x.iter() + .zip(y.iter()) + .map(|(&xi, &yi)| yi - (slope * xi + intercept)) + .collect() +} + +/// Compute time-based derivative using central differences +pub fn time_derivative(data: &[f64], times: &[f64]) -> Vec { + if data.len() < 2 || times.len() != data.len() { + return vec![0.0; data.len()]; + } + + let mut result = Vec::with_capacity(data.len()); + + // Forward difference for first point + let dt = times[1] - times[0]; + if dt.abs() > f64::EPSILON { + result.push((data[1] - data[0]) / dt); + } else { + result.push(0.0); + } + + // Central differences for interior points + for i in 1..data.len() - 1 { + let dt = times[i + 1] - times[i - 1]; + if dt.abs() > f64::EPSILON { + result.push((data[i + 1] - data[i - 1]) / dt); + } else { + result.push(0.0); + } + } + + // Backward difference for last point + let dt = times[data.len() - 1] - times[data.len() - 2]; + if dt.abs() > f64::EPSILON { + result.push((data[data.len() - 1] - data[data.len() - 2]) / dt); + } else { + result.push(0.0); + } + + result +} + +/// Compute sample-based derivative (simple differences) +pub fn sample_derivative(data: &[f64]) -> Vec { + if data.len() < 2 { + return vec![0.0; data.len()]; + } + + let mut result = Vec::with_capacity(data.len()); + + // Forward difference for first point + result.push(data[1] - data[0]); + + // Central differences for interior points + for i in 1..data.len() - 1 { + result.push((data[i + 1] - data[i - 1]) / 2.0); + } + + // Backward difference for last point + result.push(data[data.len() - 1] - data[data.len() - 2]); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_descriptive_stats() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let stats = compute_descriptive_stats(&data); + + assert_eq!(stats.count, 5); + assert!((stats.mean - 3.0).abs() < 0.001); + assert!((stats.median - 3.0).abs() < 0.001); + assert!((stats.min - 1.0).abs() < 0.001); + assert!((stats.max - 5.0).abs() < 0.001); + assert!((stats.range - 4.0).abs() < 0.001); + } + + #[test] + fn test_pearson_correlation_perfect() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![2.0, 4.0, 6.0, 8.0, 10.0]; // Perfect positive correlation + let result = pearson_correlation(&x, &y); + + assert!((result.r - 1.0).abs() < 0.001); + } + + #[test] + fn test_pearson_correlation_negative() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![10.0, 8.0, 6.0, 4.0, 2.0]; // Perfect negative correlation + let result = pearson_correlation(&x, &y); + + assert!((result.r + 1.0).abs() < 0.001); + } + + #[test] + fn test_time_derivative() { + let data = vec![0.0, 1.0, 4.0, 9.0, 16.0]; // y = x^2 at x=0,1,2,3,4 + let times = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let derivative = time_derivative(&data, ×); + + // dy/dx = 2x, so at x=2, derivative should be ~4 + assert!((derivative[2] - 4.0).abs() < 0.001); + } +} diff --git a/src/app.rs b/src/app.rs index e42c93c..a41353c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use std::sync::mpsc::{channel, Receiver, Sender}; use std::thread; +use crate::analysis::{AnalysisResult, AnalyzerRegistry}; use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; use crate::parsers::{Aim, EcuMaster, EcuType, Haltech, Link, Parseable, RomRaider, Speeduino}; @@ -118,6 +119,13 @@ pub struct UltraLogApp { pub(crate) show_computed_channels_manager: bool, /// State for the formula editor dialog pub(crate) formula_editor_state: FormulaEditorState, + // === Analysis System === + /// Registry of available analyzers + pub(crate) analyzer_registry: AnalyzerRegistry, + /// Results from running analyzers (stored per file) + pub(crate) analysis_results: HashMap>, + /// Whether to show the analysis panel + pub(crate) show_analysis_panel: bool, } impl Default for UltraLogApp { @@ -163,6 +171,9 @@ impl Default for UltraLogApp { file_computed_channels: HashMap::new(), show_computed_channels_manager: false, formula_editor_state: FormulaEditorState::default(), + analyzer_registry: AnalyzerRegistry::new(), + analysis_results: HashMap::new(), + show_analysis_panel: false, } } } @@ -1295,6 +1306,7 @@ impl eframe::App for UltraLogApp { self.render_update_dialog(ctx); self.render_computed_channels_manager(ctx); self.render_formula_editor(ctx); + self.render_analysis_panel(ctx); // Menu bar at top with padding let menu_frame = egui::Frame::NONE.inner_margin(egui::Margin { diff --git a/src/computed.rs b/src/computed.rs index 8d62447..c11c94f 100644 --- a/src/computed.rs +++ b/src/computed.rs @@ -26,6 +26,12 @@ pub struct ComputedChannelTemplate { pub created_at: u64, /// Last modified timestamp (unix seconds) pub modified_at: u64, + /// Whether this is a built-in template (vs user-created) + #[serde(default)] + pub is_builtin: bool, + /// Category for grouping (e.g., "Rate", "Engine", "Anomaly", "Smoothing") + #[serde(default)] + pub category: String, } impl ComputedChannelTemplate { @@ -44,6 +50,34 @@ impl ComputedChannelTemplate { description, created_at: now, modified_at: now, + is_builtin: false, + category: String::new(), + } + } + + /// Create a built-in template with category + pub fn builtin( + name: &str, + formula: &str, + unit: &str, + category: &str, + description: &str, + ) -> Self { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + formula: formula.to_string(), + unit: unit.to_string(), + description: description.to_string(), + created_at: now, + modified_at: now, + is_builtin: true, + category: category.to_string(), } } @@ -201,7 +235,7 @@ impl ComputedChannelLibrary { Self::get_config_dir().map(|p| p.join("computed_channels.json")) } - /// Load the library from disk + /// Load the library from disk, seeding with built-ins if empty pub fn load() -> Self { let path = match Self::get_library_path() { Some(p) => p, @@ -209,33 +243,55 @@ impl ComputedChannelLibrary { tracing::warn!( "Could not determine config directory for computed channels library" ); - return Self::new(); + return Self::new_with_builtins(); } }; if !path.exists() { - tracing::info!("Computed channels library not found, using empty library"); - return Self::new(); + tracing::info!("Computed channels library not found, seeding with built-in templates"); + let library = Self::new_with_builtins(); + // Save the seeded library + if let Err(e) = library.save() { + tracing::warn!("Failed to save initial library: {}", e); + } + return library; } match std::fs::read_to_string(&path) { - Ok(content) => match serde_json::from_str(&content) { + Ok(content) => match serde_json::from_str::(&content) { Ok(library) => { + // If existing library is empty, seed with built-ins + if library.templates.is_empty() { + tracing::info!("Library empty, seeding with built-in templates"); + let seeded = Self::new_with_builtins(); + if let Err(e) = seeded.save() { + tracing::warn!("Failed to save seeded library: {}", e); + } + return seeded; + } tracing::info!("Loaded computed channels library from {:?}", path); library } Err(e) => { tracing::error!("Failed to parse computed channels library: {}", e); - Self::new() + Self::new_with_builtins() } }, Err(e) => { tracing::error!("Failed to read computed channels library: {}", e); - Self::new() + Self::new_with_builtins() } } } + /// Create a new library pre-seeded with built-in templates + pub fn new_with_builtins() -> Self { + Self { + version: Self::CURRENT_VERSION, + templates: get_builtin_templates(), + } + } + /// Save the library to disk pub fn save(&self) -> Result<(), String> { let path = Self::get_library_path() @@ -258,6 +314,86 @@ impl ComputedChannelLibrary { } } +/// Get the default built-in templates for quick computed channels +pub fn get_builtin_templates() -> Vec { + vec![ + // Rate of Change templates + ComputedChannelTemplate::builtin( + "RPM Delta", + "RPM - RPM[-1]", + "RPM/sample", + "Rate", + "RPM change between consecutive samples", + ), + ComputedChannelTemplate::builtin( + "TPS Rate", + "(TPS - TPS@-0.1s) * 10", + "%/s", + "Rate", + "Throttle position rate of change per second", + ), + ComputedChannelTemplate::builtin( + "Boost Rate", + "(MAP - MAP@-0.1s) * 10", + "kPa/s", + "Rate", + "Boost/MAP pressure rate of change per second", + ), + // Engine Calculations + ComputedChannelTemplate::builtin( + "AFR Deviation", + "(AFR - 14.7) / 14.7 * 100", + "%", + "Engine", + "Percent deviation from stoichiometric AFR (14.7)", + ), + ComputedChannelTemplate::builtin( + "Lambda", + "AFR / 14.7", + "λ", + "Engine", + "Lambda value calculated from AFR", + ), + ComputedChannelTemplate::builtin( + "Load Estimate", + "MAP / 101.325 * 100", + "%", + "Engine", + "Engine load estimate from MAP (% of atmospheric)", + ), + // Smoothing + ComputedChannelTemplate::builtin( + "RPM Smoothed", + "(RPM + RPM[-1] + RPM[-2]) / 3", + "RPM", + "Smoothing", + "3-sample moving average of RPM", + ), + // Z-Score / Statistical Outlier Detection + ComputedChannelTemplate::builtin( + "RPM Z-Score", + "(RPM - _mean_RPM) / _stdev_RPM", + "σ", + "Anomaly", + "Z-score of RPM (values > 3 or < -3 are statistical outliers)", + ), + ComputedChannelTemplate::builtin( + "AFR Z-Score", + "(AFR - _mean_AFR) / _stdev_AFR", + "σ", + "Anomaly", + "Z-score of AFR (values > 3 or < -3 are statistical outliers)", + ), + ComputedChannelTemplate::builtin( + "MAP Z-Score", + "(MAP - _mean_MAP) / _stdev_MAP", + "σ", + "Anomaly", + "Z-score of MAP (values > 3 or < -3 are statistical outliers)", + ), + ] +} + /// State for the formula editor dialog #[derive(Clone, Debug, Default)] pub struct FormulaEditorState { diff --git a/src/expression.rs b/src/expression.rs index 95a669f..18d7131 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -1,7 +1,8 @@ //! Expression parsing and evaluation engine for computed channels //! //! This module handles parsing mathematical formulas that reference channel data, -//! including support for time-shifted values (both index-based and time-based). +//! including support for time-shifted values (both index-based and time-based), +//! and pre-computed channel statistics for anomaly detection. use crate::computed::{ChannelReference, TimeShift}; use crate::parsers::types::Value; @@ -10,6 +11,71 @@ use regex::Regex; use std::collections::HashMap; use std::sync::LazyLock; +/// Pre-computed statistics for a channel, used for anomaly detection formulas +#[derive(Clone, Debug, Default)] +pub struct ChannelStatistics { + /// Arithmetic mean of all values + pub mean: f64, + /// Standard deviation of all values + pub stdev: f64, + /// Minimum value + pub min: f64, + /// Maximum value + pub max: f64, + /// Range (max - min) + pub range: f64, +} + +/// Compute statistics for a single channel +pub fn compute_channel_statistics( + channel_idx: usize, + log_data: &[Vec], +) -> ChannelStatistics { + if log_data.is_empty() { + return ChannelStatistics::default(); + } + + let values: Vec = log_data + .iter() + .filter_map(|row| row.get(channel_idx).map(|v| v.as_f64())) + .filter(|v| v.is_finite()) + .collect(); + + if values.is_empty() { + return ChannelStatistics::default(); + } + + let n = values.len() as f64; + let sum: f64 = values.iter().sum(); + let mean = sum / n; + + let variance: f64 = values.iter().map(|v| (v - mean).powi(2)).sum::() / n; + let stdev = variance.sqrt(); + + let min = values.iter().cloned().fold(f64::INFINITY, f64::min); + let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + ChannelStatistics { + mean, + stdev, + min, + max, + range: max - min, + } +} + +/// Compute statistics for all channels in a log +pub fn compute_all_channel_statistics( + channel_names: &[String], + log_data: &[Vec], +) -> HashMap { + let mut stats = HashMap::new(); + for (idx, name) in channel_names.iter().enumerate() { + stats.insert(name.clone(), compute_channel_statistics(idx, log_data)); + } + stats +} + /// Regex for parsing quoted channel references with optional time shifts /// Pattern: "Channel Name" (anything in quotes) with optional time shift static QUOTED_CHANNEL_REGEX: LazyLock = LazyLock::new(|| { @@ -30,6 +96,9 @@ const RESERVED_NAMES: &[&str] = &[ "fract", "signum", "max", "min", "pi", "e", "tau", "phi", ]; +/// Prefixes for statistical variables that should not be treated as channel names +const STATS_PREFIXES: &[&str] = &["_mean_", "_stdev_", "_min_", "_max_", "_range_"]; + /// Extract all channel references from a formula pub fn extract_channel_references(formula: &str) -> Vec { let mut references = Vec::new(); @@ -62,6 +131,11 @@ pub fn extract_channel_references(formula: &str) -> Vec { continue; } + // Skip statistical variable references (e.g., _mean_RPM, _stdev_AFR) + if STATS_PREFIXES.iter().any(|p| full_match.starts_with(p)) { + continue; + } + // Skip if this position is inside a quoted reference let start_pos = caps.get(0).unwrap().start(); let is_inside_quoted = references.iter().any(|r| { @@ -144,6 +218,16 @@ pub fn validate_formula(formula: &str, available_channels: &[String]) -> Result< ctx.var(&var_name, 1.0); } + // Also set dummy values for statistical variables (for anomaly detection formulas) + for channel in available_channels { + let safe_name = sanitize_var_name(channel); + ctx.var(format!("_mean_{}", safe_name), 1.0); + ctx.var(format!("_stdev_{}", safe_name), 1.0); + ctx.var(format!("_min_{}", safe_name), 0.0); + ctx.var(format!("_max_{}", safe_name), 2.0); + ctx.var(format!("_range_{}", safe_name), 2.0); + } + match test_formula.parse::() { Ok(expr) => { // Try to evaluate with dummy values @@ -213,11 +297,28 @@ pub fn build_channel_bindings( } /// Evaluate a formula for all records in the log +/// +/// If `statistics` is provided, injects statistical variables for each channel: +/// - `_mean_ChannelName`, `_stdev_ChannelName`, `_min_ChannelName`, `_max_ChannelName`, `_range_ChannelName` pub fn evaluate_all_records( formula: &str, bindings: &HashMap, log_data: &[Vec], times: &[f64], +) -> Result, String> { + evaluate_all_records_with_stats(formula, bindings, log_data, times, None) +} + +/// Evaluate a formula for all records in the log with optional pre-computed statistics +/// +/// If `statistics` is provided, injects statistical variables for each channel: +/// - `_mean_ChannelName`, `_stdev_ChannelName`, `_min_ChannelName`, `_max_ChannelName`, `_range_ChannelName` +pub fn evaluate_all_records_with_stats( + formula: &str, + bindings: &HashMap, + log_data: &[Vec], + times: &[f64], + statistics: Option<&HashMap>, ) -> Result, String> { if log_data.is_empty() { return Ok(Vec::new()); @@ -245,6 +346,18 @@ pub fn evaluate_all_records( ctx.var(&var_name, value); } + // Inject statistics variables if provided + if let Some(stats) = statistics { + for (channel_name, channel_stats) in stats { + let safe_name = sanitize_var_name(channel_name); + ctx.var(format!("_mean_{}", safe_name), channel_stats.mean); + ctx.var(format!("_stdev_{}", safe_name), channel_stats.stdev); + ctx.var(format!("_min_{}", safe_name), channel_stats.min); + ctx.var(format!("_max_{}", safe_name), channel_stats.max); + ctx.var(format!("_range_{}", safe_name), channel_stats.range); + } + } + match expr.eval_with_context(&ctx) { Ok(value) => { // Handle NaN and infinity diff --git a/src/lib.rs b/src/lib.rs index 9045374..6e3ff55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ //! - [`normalize`] - Field name normalization for standardizing channel names //! - [`updater`] - Auto-update functionality for checking and downloading updates //! - [`analytics`] - Anonymous usage analytics via PostHog +//! - [`analysis`] - Signal processing and statistical analysis algorithms //! - [`ui`] - User interface components //! - `sidebar` - File list and view options //! - `channels` - Channel selection and display @@ -21,6 +22,7 @@ //! - `toast` - Toast notification system //! - `icons` - Custom icon drawing utilities +pub mod analysis; pub mod analytics; pub mod app; pub mod computed; diff --git a/src/normalize.rs b/src/normalize.rs index b5b91b0..64f20c9 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -11,7 +11,7 @@ static NORMALIZATION_MAP: LazyLock>> = LazyLock::new(|| { let mut map = HashMap::new(); - // AFR (Air Fuel Ratio) + // AFR (Air Fuel Ratio) - generic/overall readings map.insert( "AFR", vec![ @@ -20,10 +20,14 @@ static NORMALIZATION_MAP: LazyLock>> = "Aft", "Act AFR", "AFR", - "AFR1", - "WB2 AFR 1", "Air Fuel Ratio", "AFR_R_BANK", + // Wideband O2 sensors - overall/combined readings only + "Wideband O2 Overall", + "Wideband O2", + "WB O2", + "O2 Wideband", + "Wideband Overall", ], ); @@ -39,23 +43,41 @@ static NORMALIZATION_MAP: LazyLock>> = ], ); - // AFR 1 - map.insert("AFR 1", vec!["AFR_1", "AFR 1"]); + // AFR 1 - first sensor/bank + map.insert( + "AFR Channel 1", + vec![ + "AFR_1", + "AFR 1", + "AFR1", + "WB2 AFR 1", + "Wideband O2 1", + "WB O2 1", + "Wideband 1", + ], + ); - // AFR 1 Error + // AFR 2 - second sensor/bank map.insert( - "AFR 1 Error", + "AFR Channel 2", vec![ - "AFR_Error", - "AFR_1_Error", - "AFR 1 Error", - "KO2_AFR_CORR", - "AFR_1_Error", + "Aft2", + "AFR 2", + "AFR_2", + "afr_2", + "AFR2", + "WB2 AFR 2", + "Wideband O2 2", + "WB O2 2", + "Wideband 2", ], ); - // AFR 2 - map.insert("AFR 2", vec!["Aft2", "AFR 2", "AFR_2", "afr_2"]); + // AFR 1 Error + map.insert( + "AFR 1 Error", + vec!["AFR_Error", "AFR_1_Error", "AFR 1 Error", "KO2_AFR_CORR"], + ); // Battery Voltage map.insert( @@ -132,6 +154,7 @@ static NORMALIZATION_MAP: LazyLock>> = "IAT - Inlet Air Temp", "IAT Intake Air Temp", "Intake Air Temp", + "Intake Air Temperature", ], ); @@ -461,9 +484,20 @@ mod tests { #[test] fn test_normalize_afr() { + // Generic AFR channels assert_eq!(normalize_channel_name("Act_AFR"), "AFR"); assert_eq!(normalize_channel_name("R_EGO"), "AFR"); assert_eq!(normalize_channel_name("Air Fuel Ratio"), "AFR"); + // Wideband O2 overall/generic should normalize to AFR + assert_eq!(normalize_channel_name("Wideband O2 Overall"), "AFR"); + assert_eq!(normalize_channel_name("Wideband O2"), "AFR"); + assert_eq!(normalize_channel_name("WB O2"), "AFR"); + // Numbered sensors should normalize to AFR Channel 1, AFR Channel 2 (not generic AFR) + assert_eq!(normalize_channel_name("Wideband O2 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband O2 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("WB O2 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband 2"), "AFR Channel 2"); } #[test] diff --git a/src/ui/analysis_panel.rs b/src/ui/analysis_panel.rs new file mode 100644 index 0000000..a59e09b --- /dev/null +++ b/src/ui/analysis_panel.rs @@ -0,0 +1,1082 @@ +//! Analysis Panel UI. +//! +//! Provides a window for users to run analysis algorithms on the active log file, +//! including signal processing filters and statistical analyzers. + +use eframe::egui; +use std::collections::HashMap; + +use crate::analysis::{AnalysisResult, Analyzer, AnalyzerConfig, LogDataAccess}; +use crate::app::UltraLogApp; +use crate::computed::{ComputedChannel, ComputedChannelTemplate}; +use crate::normalize::sort_channels_by_priority; +use crate::parsers::types::ComputedChannelInfo; +use crate::parsers::Channel; +use crate::state::{SelectedChannel, CHART_COLORS}; + +/// Info about an analyzer for display (avoids borrow issues) +struct AnalyzerInfo { + id: String, + name: String, + description: String, + category: String, + config: AnalyzerConfig, +} + +/// Parameter definition for UI rendering +#[derive(Clone)] +struct ParamDef { + key: String, + label: String, + param_type: ParamType, + /// Tooltip with helpful information about expected channel types + tooltip: Option, +} + +#[derive(Clone)] +enum ParamType { + Channel, // Channel selector dropdown + Integer { min: i32, max: i32 }, + Float { min: f64, max: f64 }, + Boolean, +} + +impl UltraLogApp { + /// Render the analysis panel window + pub fn render_analysis_panel(&mut self, ctx: &egui::Context) { + if !self.show_analysis_panel { + return; + } + + let mut open = true; + + egui::Window::new("Analysis Tools") + .open(&mut open) + .resizable(true) + .default_width(550.0) + .default_height(500.0) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + // Header + ui.heading("Signal Analysis"); + ui.add_space(4.0); + ui.label( + egui::RichText::new( + "Run signal processing and statistical analysis on log data.", + ) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + ui.separator(); + ui.add_space(4.0); + + // Check if we have a file loaded + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + + if !has_file { + ui.vertical_centered(|ui| { + ui.add_space(40.0); + ui.label( + egui::RichText::new("No log file loaded") + .color(egui::Color32::GRAY) + .size(16.0), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Load a log file to access analysis tools.") + .color(egui::Color32::GRAY) + .small(), + ); + ui.add_space(40.0); + }); + } else { + // Show available analyzers grouped by category + self.render_analyzer_list(ui); + } + }); + + if !open { + self.show_analysis_panel = false; + } + } + + /// Render the list of available analyzers + fn render_analyzer_list(&mut self, ui: &mut egui::Ui) { + // Get channel names from the currently selected file, with normalization and sorting + let (channel_names, channel_display_names): (Vec, Vec) = + if let Some(file_idx) = self.selected_file { + if let Some(file) = self.files.get(file_idx) { + let raw_names = file.log.channel_names(); + + // Sort channels like the main sidebar: normalized first, then alphabetically + let sorted = sort_channels_by_priority( + raw_names.len(), + |idx| raw_names.get(idx).cloned().unwrap_or_default(), + self.field_normalization, + Some(&self.custom_normalizations), + ); + + // Build parallel vectors: raw names for matching, display names for UI + let mut raw_sorted = Vec::with_capacity(sorted.len()); + let mut display_sorted = Vec::with_capacity(sorted.len()); + + for (idx, display_name, _is_normalized) in sorted { + if let Some(raw_name) = raw_names.get(idx) { + raw_sorted.push(raw_name.clone()); + display_sorted.push(display_name); + } + } + + (raw_sorted, display_sorted) + } else { + (vec![], vec![]) + } + } else { + (vec![], vec![]) + }; + + // Collect analyzer info upfront to avoid borrow issues + let analyzer_infos: Vec = self + .analyzer_registry + .all() + .iter() + .map(|a| AnalyzerInfo { + id: a.id().to_string(), + name: a.name().to_string(), + description: a.description().to_string(), + category: a.category().to_string(), + config: a.get_config(), + }) + .collect(); + + // Group by category + let mut categories: HashMap> = HashMap::new(); + for info in &analyzer_infos { + categories + .entry(info.category.clone()) + .or_default() + .push(info); + } + + let mut sorted_categories: Vec<_> = categories.keys().cloned().collect(); + sorted_categories.sort(); + + // Track actions to perform after rendering + let mut analyzer_to_run: Option = None; + let mut analyzer_to_run_and_chart: Option = None; + let mut config_updates: Vec<(String, AnalyzerConfig)> = Vec::new(); + let mut result_to_add: Option = None; + let mut result_to_remove: Option = None; + + egui::ScrollArea::vertical() + .id_salt("analysis_panel_scroll") + .show(ui, |ui| { + // Show analysis results at the TOP if any exist + if let Some(file_idx) = self.selected_file { + if let Some(results) = self.analysis_results.get(&file_idx) { + if !results.is_empty() { + egui::CollapsingHeader::new( + egui::RichText::new(format!("Results ({})", results.len())) + .strong() + .size(14.0), + ) + .default_open(true) + .show(ui, |ui| { + for (i, result) in results.iter().enumerate() { + if let Some(action) = + Self::render_analysis_result_with_actions(ui, result, i) + { + match action { + ResultAction::AddToChart => result_to_add = Some(i), + ResultAction::Remove => result_to_remove = Some(i), + } + } + } + }); + + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + } + } + } + + // Show available analyzers grouped by category + for category in &sorted_categories { + if let Some(analyzers) = categories.get(category) { + ui.add_space(4.0); + + egui::CollapsingHeader::new( + egui::RichText::new(category).strong().size(14.0), + ) + .default_open(true) + .show(ui, |ui| { + for info in analyzers { + if let Some((id, action)) = Self::render_analyzer_card_with_config( + ui, + info, + &channel_names, + &channel_display_names, + ) { + match action { + AnalyzerAction::Run => { + analyzer_to_run = Some(id); + } + AnalyzerAction::RunAndChart => { + analyzer_to_run_and_chart = Some(id); + } + AnalyzerAction::UpdateConfig(config) => { + config_updates.push((id, config)); + } + } + } + } + }); + + ui.add_space(4.0); + } + } + }); + + // Handle deferred actions + + // Apply config updates + for (id, config) in config_updates { + if let Some(analyzer) = self.analyzer_registry.find_by_id_mut(&id) { + analyzer.set_config(&config); + } + } + + // Run analyzer (just run, don't add to chart) + if let Some(id) = analyzer_to_run { + self.run_analyzer(&id); + } + + // Run analyzer AND add to chart immediately + if let Some(id) = analyzer_to_run_and_chart { + self.run_analyzer_and_chart(&id); + } + + // Add result to chart + if let Some(idx) = result_to_add { + self.add_analysis_result_to_chart(idx); + } + + // Remove result + if let Some(idx) = result_to_remove { + if let Some(file_idx) = self.selected_file { + if let Some(results) = self.analysis_results.get_mut(&file_idx) { + if idx < results.len() { + results.remove(idx); + } + } + } + } + } + + /// Render a single analyzer card with configuration options + /// Returns Some((id, action)) if an action was triggered + /// + /// `channel_names` - raw channel names (for config storage and matching) + /// `channel_display_names` - display names (normalized if enabled) + fn render_analyzer_card_with_config( + ui: &mut egui::Ui, + info: &AnalyzerInfo, + channel_names: &[String], + channel_display_names: &[String], + ) -> Option<(String, AnalyzerAction)> { + let mut action: Option = None; + let mut new_config = info.config.clone(); + + // Get parameter definitions for this analyzer + let param_defs = get_analyzer_params(&info.id); + + // Check if required channels are available + let channels_available = check_channels_available( + &new_config, + ¶m_defs, + channel_names, + channel_display_names, + ); + + let card_bg = if channels_available { + egui::Color32::from_rgb(40, 45, 40) + } else { + egui::Color32::from_rgb(45, 45, 45) + }; + + egui::Frame::NONE + .fill(card_bg) + .corner_radius(6) + .inner_margin(10.0) + .outer_margin(egui::Margin::symmetric(0, 2)) + .show(ui, |ui| { + // Header row with name and Run button + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(&info.name).strong()); + ui.label( + egui::RichText::new(&info.description) + .color(egui::Color32::GRAY) + .small(), + ); + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // "Run & Chart" button (primary action) + let run_chart_btn = egui::Button::new("Run & Chart") + .fill(egui::Color32::from_rgb(60, 100, 60)); + let run_chart_response = ui.add_enabled(channels_available, run_chart_btn); + + if run_chart_response.clicked() { + action = Some(AnalyzerAction::RunAndChart); + } + + if !channels_available { + run_chart_response.on_hover_text("Select valid channels first"); + } else { + run_chart_response + .on_hover_text("Run analysis and add result to chart"); + } + + ui.add_space(4.0); + + // "Run" button (secondary - just adds to results) + let run_btn = egui::Button::new("Run"); + let run_response = ui.add_enabled(channels_available, run_btn); + + if run_response.clicked() { + action = Some(AnalyzerAction::Run); + } + + if !channels_available { + run_response.on_hover_text("Select valid channels first"); + } else { + run_response.on_hover_text("Run analysis (add to chart later)"); + } + }); + }); + + ui.add_space(6.0); + + // Parameter configuration + let mut config_changed = false; + + for param in ¶m_defs { + ui.horizontal(|ui| { + let label_response = ui.label( + egui::RichText::new(¶m.label) + .color(egui::Color32::GRAY) + .small(), + ); + + // Show tooltip if available + if let Some(tooltip) = ¶m.tooltip { + label_response.on_hover_text(tooltip); + } + + ui.add_space(4.0); + + match ¶m.param_type { + ParamType::Channel => { + let config_value = new_config + .parameters + .get(¶m.key) + .cloned() + .unwrap_or_default(); + + // Resolve config value to raw channel name + // Config might have a normalized name (e.g., "AFR") that needs + // to be resolved to the actual raw name (e.g., "Wideband O2 Overall") + let (current_raw, current_display) = if let Some(idx) = + channel_names + .iter() + .position(|n| n.eq_ignore_ascii_case(&config_value)) + { + // Config value matches a raw name directly + ( + channel_names[idx].clone(), + channel_display_names + .get(idx) + .cloned() + .unwrap_or(config_value.clone()), + ) + } else if let Some(idx) = channel_display_names + .iter() + .position(|n| n.eq_ignore_ascii_case(&config_value)) + { + // Config value matches a display/normalized name - resolve to raw + let raw = channel_names + .get(idx) + .cloned() + .unwrap_or(config_value.clone()); + let display = channel_display_names[idx].clone(); + // Auto-update config to use the raw name + new_config.parameters.insert(param.key.clone(), raw.clone()); + config_changed = true; + (raw, display) + } else { + // No match found, keep as-is + (config_value.clone(), config_value) + }; + + let combo_response = egui::ComboBox::from_id_salt(format!( + "{}_{}_combo", + info.id, param.key + )) + .width(180.0) + .selected_text(¤t_display) + .show_ui(ui, |ui| { + // Show display names but store raw names + for (raw_name, display_name) in + channel_names.iter().zip(channel_display_names.iter()) + { + if ui + .selectable_label( + current_raw == *raw_name, + display_name, + ) + .clicked() + { + new_config + .parameters + .insert(param.key.clone(), raw_name.clone()); + config_changed = true; + } + } + }); + + // Show tooltip on combo box too + if let Some(tooltip) = ¶m.tooltip { + combo_response.response.on_hover_text(tooltip); + } + } + ParamType::Integer { min, max } => { + let current: i32 = new_config + .parameters + .get(¶m.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(*min); + + let mut value = current; + if ui + .add( + egui::DragValue::new(&mut value) + .range(*min..=*max) + .speed(1), + ) + .changed() + { + new_config + .parameters + .insert(param.key.clone(), value.to_string()); + config_changed = true; + } + } + ParamType::Float { min, max } => { + let current: f64 = new_config + .parameters + .get(¶m.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(*min); + + let mut value = current; + if ui + .add( + egui::DragValue::new(&mut value) + .range(*min..=*max) + .speed(0.01) + .fixed_decimals(2), + ) + .changed() + { + new_config + .parameters + .insert(param.key.clone(), value.to_string()); + config_changed = true; + } + } + ParamType::Boolean => { + let current: bool = new_config + .parameters + .get(¶m.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(false); + + let mut value = current; + if ui.checkbox(&mut value, "").changed() { + new_config + .parameters + .insert(param.key.clone(), value.to_string()); + config_changed = true; + } + } + } + }); + } + + if config_changed && action.is_none() { + action = Some(AnalyzerAction::UpdateConfig(new_config.clone())); + } + }); + + action.map(|a| (info.id.clone(), a)) + } + + /// Render an analysis result with Add to Chart and Remove buttons + fn render_analysis_result_with_actions( + ui: &mut egui::Ui, + result: &AnalysisResult, + _index: usize, + ) -> Option { + let mut action: Option = None; + + egui::Frame::NONE + .fill(egui::Color32::from_rgb(35, 40, 45)) + .corner_radius(6) + .inner_margin(8.0) + .outer_margin(egui::Margin::symmetric(0, 2)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&result.name).strong()); + if !result.unit.is_empty() { + ui.label( + egui::RichText::new(format!("({})", result.unit)) + .color(egui::Color32::GRAY) + .small(), + ); + } + }); + + // Show basic stats about the result + if !result.values.is_empty() { + let min = result.values.iter().cloned().fold(f64::INFINITY, f64::min); + let max = result + .values + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mean: f64 = + result.values.iter().sum::() / result.values.len() as f64; + + ui.label( + egui::RichText::new(format!( + "Min: {:.2} Max: {:.2} Mean: {:.2} ({} pts)", + min, + max, + mean, + result.values.len() + )) + .color(egui::Color32::GRAY) + .small(), + ); + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Remove button + if ui + .small_button("x") + .on_hover_text("Remove result") + .clicked() + { + action = Some(ResultAction::Remove); + } + + ui.add_space(4.0); + + // Add to chart button + if ui + .button("+ Chart") + .on_hover_text("Add to chart as a channel") + .clicked() + { + action = Some(ResultAction::AddToChart); + } + }); + }); + }); + + action + } + + /// Add an analysis result to the chart as a computed channel + fn add_analysis_result_to_chart(&mut self, result_index: usize) { + let file_idx = match self.selected_file { + Some(idx) => idx, + None => return, + }; + + let tab_idx = match self.active_tab { + Some(idx) => idx, + None => return, + }; + + // Get the result + let result = match self.analysis_results.get(&file_idx) { + Some(results) => match results.get(result_index) { + Some(r) => r.clone(), + None => return, + }, + None => return, + }; + + // Create a computed channel from the result + let template = ComputedChannelTemplate::new( + result.name.clone(), + format!("_analysis_result_{}", result_index), // Placeholder formula + result.unit.clone(), + format!("Analysis result: {}", result.metadata.algorithm), + ); + + let mut computed = ComputedChannel::from_template(template); + computed.cached_data = Some(result.values.clone()); + + // Add to file's computed channels + let computed_channels = self.file_computed_channels.entry(file_idx).or_default(); + computed_channels.push(computed.clone()); + + // Get the virtual channel index + let file = match self.files.get(file_idx) { + Some(f) => f, + None => return, + }; + let base_channel_count = file.log.channels.len(); + let virtual_channel_index = base_channel_count + computed_channels.len() - 1; + + // Find next available color + let used_colors: std::collections::HashSet = self.tabs[tab_idx] + .selected_channels + .iter() + .map(|c| c.color_index) + .collect(); + let color_index = (0..CHART_COLORS.len()) + .find(|&i| !used_colors.contains(&i)) + .unwrap_or(0); + + // Create the channel enum variant + let channel = Channel::Computed(ComputedChannelInfo { + name: computed.name().to_string(), + formula: computed.formula().to_string(), + unit: computed.unit().to_string(), + }); + + // Add to selected channels + self.tabs[tab_idx].selected_channels.push(SelectedChannel { + file_index: file_idx, + channel_index: virtual_channel_index, + channel, + color_index, + }); + + self.show_toast_success(&format!("Added '{}' to chart", result.name)); + } + + /// Run an analyzer by its ID + fn run_analyzer(&mut self, analyzer_id: &str) { + let file_idx = match self.selected_file { + Some(idx) => idx, + None => { + self.show_toast_error("No file selected"); + return; + } + }; + + // Get the log data + let log = match self.files.get(file_idx) { + Some(file) => &file.log, + None => { + self.show_toast_error("File not found"); + return; + } + }; + + // Find and run the analyzer + // We need to clone the analyzer to avoid borrow issues + let analyzer_clone: Option> = self + .analyzer_registry + .find_by_id(analyzer_id) + .map(|a| a.clone_box()); + + if let Some(analyzer) = analyzer_clone { + match analyzer.analyze(log) { + Ok(result) => { + let result_name = result.name.clone(); + self.analysis_results + .entry(file_idx) + .or_default() + .push(result); + self.show_toast_success(&format!("Analysis complete: {}", result_name)); + } + Err(e) => { + self.show_toast_error(&format!("Analysis failed: {}", e)); + } + } + } else { + self.show_toast_error(&format!("Analyzer not found: {}", analyzer_id)); + } + } + + /// Run an analyzer and immediately add the result to the chart + fn run_analyzer_and_chart(&mut self, analyzer_id: &str) { + let file_idx = match self.selected_file { + Some(idx) => idx, + None => { + self.show_toast_error("No file selected"); + return; + } + }; + + // Get the log data + let log = match self.files.get(file_idx) { + Some(file) => &file.log, + None => { + self.show_toast_error("File not found"); + return; + } + }; + + // Find and run the analyzer + let analyzer_clone: Option> = self + .analyzer_registry + .find_by_id(analyzer_id) + .map(|a| a.clone_box()); + + if let Some(analyzer) = analyzer_clone { + match analyzer.analyze(log) { + Ok(result) => { + let result_name = result.name.clone(); + + // Add to results + self.analysis_results + .entry(file_idx) + .or_default() + .push(result); + + // Get the index of the result we just added + let result_idx = self + .analysis_results + .get(&file_idx) + .map(|r| r.len().saturating_sub(1)) + .unwrap_or(0); + + // Immediately add to chart + self.add_analysis_result_to_chart(result_idx); + + self.show_toast_success(&format!("'{}' added to chart", result_name)); + } + Err(e) => { + self.show_toast_error(&format!("Analysis failed: {}", e)); + } + } + } else { + self.show_toast_error(&format!("Analyzer not found: {}", analyzer_id)); + } + } +} + +/// Actions that can be triggered by analyzer card +enum AnalyzerAction { + Run, + RunAndChart, + UpdateConfig(AnalyzerConfig), +} + +/// Actions for analysis results +enum ResultAction { + AddToChart, + Remove, +} + +/// Get parameter definitions for a specific analyzer +fn get_analyzer_params(analyzer_id: &str) -> Vec { + match analyzer_id { + // ============== Filters ============== + "moving_average" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Any numeric channel to smooth".to_string()), + }, + ParamDef { + key: "window_size".to_string(), + label: "Window:".to_string(), + param_type: ParamType::Integer { min: 2, max: 100 }, + tooltip: Some("Number of samples to average (larger = smoother)".to_string()), + }, + ], + "exponential_moving_average" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Any numeric channel to smooth".to_string()), + }, + ParamDef { + key: "alpha".to_string(), + label: "Alpha:".to_string(), + param_type: ParamType::Float { min: 0.01, max: 1.0 }, + tooltip: Some("Smoothing factor: 0.1 = very smooth, 0.5 = moderate, 0.9 = responsive".to_string()), + }, + ], + "median_filter" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel with spike noise to remove".to_string()), + }, + ParamDef { + key: "window_size".to_string(), + label: "Window:".to_string(), + param_type: ParamType::Integer { min: 3, max: 51 }, + tooltip: Some("Must be odd number. Larger = removes wider spikes".to_string()), + }, + ], + "butterworth_lowpass" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel to filter. Common: RPM, MAP, TPS, AFR, Knock".to_string()), + }, + ParamDef { + key: "cutoff_normalized".to_string(), + label: "Cutoff:".to_string(), + param_type: ParamType::Float { min: 0.01, max: 0.49 }, + tooltip: Some("Normalized cutoff (0-0.5). 0.1 = 10% of sample rate. Lower = more smoothing".to_string()), + }, + ParamDef { + key: "order".to_string(), + label: "Order:".to_string(), + param_type: ParamType::Integer { min: 1, max: 8 }, + tooltip: Some("Filter order (1-8). Higher = sharper cutoff but more ringing. 2-4 recommended".to_string()), + }, + ], + "butterworth_highpass" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel to remove DC/drift from. Common: Knock sensor, vibration data".to_string()), + }, + ParamDef { + key: "cutoff_normalized".to_string(), + label: "Cutoff:".to_string(), + param_type: ParamType::Float { min: 0.01, max: 0.49 }, + tooltip: Some("Normalized cutoff (0-0.5). Frequencies below this are removed".to_string()), + }, + ParamDef { + key: "order".to_string(), + label: "Order:".to_string(), + param_type: ParamType::Integer { min: 1, max: 8 }, + tooltip: Some("Filter order (1-8). Higher = sharper cutoff. 2-4 recommended".to_string()), + }, + ], + + // ============== Statistics ============== + "descriptive_stats" => vec![ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Any channel to compute min/max/mean/stdev statistics".to_string()), + }], + "correlation" => vec![ + ParamDef { + key: "channel_x".to_string(), + label: "Channel X:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("First channel (e.g., RPM, TPS, MAP)".to_string()), + }, + ParamDef { + key: "channel_y".to_string(), + label: "Channel Y:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Second channel to correlate (e.g., AFR, Fuel Trim)".to_string()), + }, + ], + "rate_of_change" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel to differentiate. Common: RPM (acceleration), TPS (throttle rate)".to_string()), + }, + ParamDef { + key: "time_based".to_string(), + label: "Per second:".to_string(), + param_type: ParamType::Boolean, + tooltip: Some("If checked, rate is per second. Otherwise, per sample".to_string()), + }, + ], + + // ============== AFR Analysis ============== + "fuel_trim_drift" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Fuel Trim:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Fuel trim channel. Common names: LTFT, STFT, Long Term FT, Short Term FT, Fuel Trim, FT Bank1".to_string()), + }, + ParamDef { + key: "k".to_string(), + label: "Sensitivity (k):".to_string(), + param_type: ParamType::Float { min: 0.1, max: 10.0 }, + tooltip: Some("CUSUM slack parameter. Lower = more sensitive to small drifts. Default 2.5".to_string()), + }, + ParamDef { + key: "h".to_string(), + label: "Threshold (h):".to_string(), + param_type: ParamType::Float { min: 1.0, max: 100.0 }, + tooltip: Some("Detection threshold. Lower = faster detection, more false alarms. Default 20".to_string()), + }, + ParamDef { + key: "baseline_pct".to_string(), + label: "Baseline %:".to_string(), + param_type: ParamType::Float { min: 1.0, max: 50.0 }, + tooltip: Some("% of data from start to use as baseline. Default 10%".to_string()), + }, + ], + "rich_lean_zone" => vec![ + ParamDef { + key: "channel".to_string(), + label: "AFR/Lambda:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("AFR or Lambda channel. Auto-detects unit type. Common: AFR, Lambda, Wideband O2, O2".to_string()), + }, + ParamDef { + key: "target".to_string(), + label: "Target:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 20.0 }, + tooltip: Some("Target value. Set to 0 for auto-detect (AFR: 14.7, Lambda: 1.0). Or set manually.".to_string()), + }, + ParamDef { + key: "rich_threshold".to_string(), + label: "Rich threshold:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 3.0 }, + tooltip: Some("Set to 0 for auto-detect (AFR: 0.5, Lambda: 0.03). Rich = below (target - threshold)".to_string()), + }, + ParamDef { + key: "lean_threshold".to_string(), + label: "Lean threshold:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 3.0 }, + tooltip: Some("Set to 0 for auto-detect (AFR: 0.5, Lambda: 0.03). Lean = above (target + threshold)".to_string()), + }, + ], + "afr_deviation" => vec![ + ParamDef { + key: "channel".to_string(), + label: "AFR/Lambda:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("AFR or Lambda channel. Auto-detects unit type. Common: AFR, Lambda, Wideband O2, O2".to_string()), + }, + ParamDef { + key: "target".to_string(), + label: "Target:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 20.0 }, + tooltip: Some("Target value. Set to 0 for auto-detect (AFR: 14.7, Lambda: 1.0). Or set manually.".to_string()), + }, + ], + + // ============== Derived Calculations ============== + "volumetric_efficiency" => vec![ + ParamDef { + key: "rpm_channel".to_string(), + label: "RPM:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Engine RPM channel. Common names: RPM, Engine Speed, Engine RPM".to_string()), + }, + ParamDef { + key: "map_channel".to_string(), + label: "MAP (kPa):".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Manifold Absolute Pressure in kPa. Common names: MAP, Manifold Pressure, Boost".to_string()), + }, + ParamDef { + key: "iat_channel".to_string(), + label: "IAT:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Intake Air Temp in °C. Common names: IAT, Intake Temp, Air Temp, ACT".to_string()), + }, + ParamDef { + key: "displacement_l".to_string(), + label: "Displacement (L):".to_string(), + param_type: ParamType::Float { min: 0.1, max: 10.0 }, + tooltip: Some("Engine displacement in liters. E.g., 2.0, 3.5, 5.7".to_string()), + }, + ParamDef { + key: "is_iat_kelvin".to_string(), + label: "IAT in Kelvin:".to_string(), + param_type: ParamType::Boolean, + tooltip: Some("Check if your IAT channel is already in Kelvin (rare). Usually °C.".to_string()), + }, + ], + "injector_duty_cycle" => vec![ + ParamDef { + key: "pulse_width_channel".to_string(), + label: "Pulse Width (ms):".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Injector pulse width in milliseconds. Common names: IPW, Inj PW, Pulse Width, Fuel PW, Inj DC".to_string()), + }, + ParamDef { + key: "rpm_channel".to_string(), + label: "RPM:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Engine RPM channel. Common names: RPM, Engine Speed, Engine RPM".to_string()), + }, + ], + "lambda_calculator" => vec![ + ParamDef { + key: "afr_channel".to_string(), + label: "AFR Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Air-Fuel Ratio channel. Common names: AFR, O2, Wideband, A/F Ratio".to_string()), + }, + ParamDef { + key: "stoich_afr".to_string(), + label: "Stoich AFR:".to_string(), + param_type: ParamType::Float { min: 5.0, max: 20.0 }, + tooltip: Some("Stoichiometric AFR for your fuel. Gasoline: 14.7, E85: 9.8, E10: 14.1, Methanol: 6.4".to_string()), + }, + ], + + _ => vec![], + } +} + +/// Check if required channels are available in the log +/// +/// Checks both raw channel names and normalized display names to handle +/// cases where analyzer defaults (e.g., "AFR") need to match normalized names +/// (e.g., "Wideband O2 Overall" -> "AFR") +fn check_channels_available( + config: &AnalyzerConfig, + param_defs: &[ParamDef], + channel_names: &[String], + channel_display_names: &[String], +) -> bool { + for param in param_defs { + if matches!(param.param_type, ParamType::Channel) { + if let Some(ch) = config.parameters.get(¶m.key) { + if !ch.is_empty() { + // Check if configured channel matches raw name OR display name + let found_in_raw = channel_names + .iter() + .any(|name| name.eq_ignore_ascii_case(ch)); + let found_in_display = channel_display_names + .iter() + .any(|name| name.eq_ignore_ascii_case(ch)); + + if !found_in_raw && !found_in_display { + return false; + } + } + } + } + } + true +} diff --git a/src/ui/channels.rs b/src/ui/channels.rs index 21c70a8..f76d196 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -254,6 +254,7 @@ impl UltraLogApp { struct ChannelCardData { color: egui::Color32, display_name: String, + is_computed: bool, min_str: Option, max_str: Option, min_record: Option, @@ -280,43 +281,70 @@ impl UltraLogApp { let (min_str, max_str, min_record, max_record, min_time, max_time) = if selected.file_index < self.files.len() { let file = &self.files[selected.file_index]; - let data = file.log.get_channel_data(selected.channel_index); let times = file.log.get_times_as_f64(); + // Get data from either regular channel or computed channel + let data: Vec = if selected.channel.is_computed() { + // For computed channels, get data from file_computed_channels + let regular_count = file.log.channels.len(); + if selected.channel_index >= regular_count { + let computed_idx = selected.channel_index - regular_count; + self.file_computed_channels + .get(&selected.file_index) + .and_then(|channels| channels.get(computed_idx)) + .and_then(|c| c.cached_data.clone()) + .unwrap_or_default() + } else { + Vec::new() + } + } else { + // Regular channel data + file.log.get_channel_data(selected.channel_index) + }; + if !data.is_empty() { - // Find min and max with their indices - let (min_idx, min_val) = data + // Find min and max with their indices (filter out NaN values) + let valid_data: Vec<(usize, f64)> = data .iter() .enumerate() - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .filter(|(_, v)| v.is_finite()) .map(|(i, v)| (i, *v)) - .unwrap(); - let (max_idx, max_val) = data - .iter() - .enumerate() - .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) - .map(|(i, v)| (i, *v)) - .unwrap(); - - let source_unit = selected.channel.unit(); - let (conv_min, display_unit) = - self.unit_preferences.convert_value(min_val, source_unit); - let (conv_max, _) = - self.unit_preferences.convert_value(max_val, source_unit); - let unit_str = if display_unit.is_empty() { - String::new() + .collect(); + + if valid_data.is_empty() { + (None, None, None, None, None, None) } else { - format!(" {}", display_unit) - }; + let (min_idx, min_val) = valid_data + .iter() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .map(|(i, v)| (*i, *v)) + .unwrap(); + let (max_idx, max_val) = valid_data + .iter() + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .map(|(i, v)| (*i, *v)) + .unwrap(); + + let source_unit = selected.channel.unit(); + let (conv_min, display_unit) = + self.unit_preferences.convert_value(min_val, source_unit); + let (conv_max, _) = + self.unit_preferences.convert_value(max_val, source_unit); + let unit_str = if display_unit.is_empty() { + String::new() + } else { + format!(" {}", display_unit) + }; - ( - Some(format!("{:.1}{}", conv_min, unit_str)), - Some(format!("{:.1}{}", conv_max, unit_str)), - Some(min_idx), - Some(max_idx), - times.get(min_idx).copied(), - times.get(max_idx).copied(), - ) + ( + Some(format!("{:.1}{}", conv_min, unit_str)), + Some(format!("{:.1}{}", conv_max, unit_str)), + Some(min_idx), + Some(max_idx), + times.get(min_idx).copied(), + times.get(max_idx).copied(), + ) + } } else { (None, None, None, None, None, None) } @@ -327,6 +355,7 @@ impl UltraLogApp { channel_cards.push(ChannelCardData { color: color32, display_name, + is_computed: selected.channel.is_computed(), min_str, max_str, min_record, @@ -350,6 +379,15 @@ impl UltraLogApp { .show(ui, |ui| { ui.vertical(|ui| { ui.horizontal(|ui| { + // Show computed channel indicator + if card.is_computed { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::from_rgb(150, 200, 255)) + .strong(), + ) + .on_hover_text("Computed channel (formula-based)"); + } ui.label( egui::RichText::new(&card.display_name) .strong() diff --git a/src/ui/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs index 9092d35..30b411b 100644 --- a/src/ui/computed_channels_manager.rs +++ b/src/ui/computed_channels_manager.rs @@ -1,13 +1,17 @@ //! Computed Channels Manager UI. //! //! Provides a window for users to manage their computed channel library -//! and apply computed channels to the active log file. +//! and apply computed channels to the active log file, including quick templates +//! and anomaly detection channels. use eframe::egui; use crate::app::UltraLogApp; use crate::computed::{ComputedChannel, ComputedChannelTemplate}; -use crate::expression::{build_channel_bindings, evaluate_all_records, extract_channel_references}; +use crate::expression::{ + build_channel_bindings, compute_all_channel_statistics, evaluate_all_records, + evaluate_all_records_with_stats, extract_channel_references, +}; use crate::parsers::types::ComputedChannelInfo; use crate::parsers::Channel; use crate::state::{SelectedChannel, CHART_COLORS}; @@ -83,76 +87,161 @@ impl UltraLogApp { let mut template_to_delete: Option = None; let mut template_to_apply: Option = None; + // Group templates by category + let categories: Vec = { + let mut cats: Vec = self + .computed_library + .templates + .iter() + .map(|t| { + if t.category.is_empty() { + "Custom".to_string() + } else { + t.category.clone() + } + }) + .collect(); + cats.sort(); + cats.dedup(); + // Put common categories in a specific order + let order = ["Rate", "Engine", "Smoothing", "Anomaly", "Custom"]; + cats.sort_by_key(|c| { + order + .iter() + .position(|&o| o == c) + .unwrap_or(order.len()) + }); + cats + }; + egui::ScrollArea::vertical() .id_salt("library_templates_scroll") - .max_height(250.0) + .max_height(300.0) .show(ui, |ui| { - for template in &self.computed_library.templates { - egui::Frame::NONE - .fill(egui::Color32::from_rgb(50, 50, 50)) - .corner_radius(5.0) - .inner_margin(egui::Margin::symmetric(10, 8)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Template info - ui.vertical(|ui| { + for category in &categories { + let cat_templates: Vec<&ComputedChannelTemplate> = self + .computed_library + .templates + .iter() + .filter(|t| { + let t_cat = if t.category.is_empty() { + "Custom" + } else { + &t.category + }; + t_cat == category + }) + .collect(); + + if cat_templates.is_empty() { + continue; + } + + // Category header with color + let cat_color = match category.as_str() { + "Rate" => egui::Color32::from_rgb(100, 180, 255), + "Engine" => egui::Color32::from_rgb(255, 180, 100), + "Smoothing" => egui::Color32::from_rgb(180, 255, 100), + "Anomaly" => egui::Color32::from_rgb(255, 100, 100), + _ => egui::Color32::GRAY, + }; + + egui::CollapsingHeader::new( + egui::RichText::new(format!( + "{} ({})", + category, + cat_templates.len() + )) + .color(cat_color), + ) + .default_open(true) + .show(ui, |ui| { + for template in cat_templates { + egui::Frame::NONE + .fill(egui::Color32::from_rgb(50, 50, 50)) + .corner_radius(5.0) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&template.name) - .strong() - .color(egui::Color32::LIGHT_BLUE), - ); - if !template.unit.is_empty() { + // Template info + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Built-in indicator + if template.is_builtin { + ui.label( + egui::RichText::new("★") + .color(egui::Color32::GOLD), + ); + } + ui.label( + egui::RichText::new(&template.name) + .strong() + .color(egui::Color32::LIGHT_BLUE), + ); + if !template.unit.is_empty() { + ui.label( + egui::RichText::new(format!( + "({})", + template.unit + )) + .small() + .color(egui::Color32::GRAY), + ); + } + }); ui.label( - egui::RichText::new(format!( - "({})", - template.unit - )) - .small() - .color(egui::Color32::GRAY), + egui::RichText::new(&template.formula) + .monospace() + .small() + .color(egui::Color32::from_rgb( + 180, 180, 180, + )), ); - } - }); - ui.label( - egui::RichText::new(&template.formula) - .monospace() - .small() - .color(egui::Color32::from_rgb(180, 180, 180)), - ); - if !template.description.is_empty() { - ui.label( - egui::RichText::new(&template.description) - .small() - .color(egui::Color32::GRAY), + if !template.description.is_empty() { + ui.label( + egui::RichText::new( + &template.description, + ) + .small() + .color(egui::Color32::GRAY), + ); + } + }); + + // Buttons on the right + ui.with_layout( + egui::Layout::right_to_left( + egui::Align::Center, + ), + |ui| { + if ui.small_button("Delete").clicked() { + template_to_delete = + Some(template.id.clone()); + } + if ui.small_button("Edit").clicked() { + template_to_edit = + Some(template.id.clone()); + } + if self.active_tab.is_some() + && ui + .button( + egui::RichText::new("Apply") + .color( + egui::Color32::WHITE, + ), + ) + .clicked() + { + template_to_apply = + Some((*template).clone()); + } + }, ); - } + }); }); - - // Buttons on the right - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui.small_button("Delete").clicked() { - template_to_delete = Some(template.id.clone()); - } - if ui.small_button("Edit").clicked() { - template_to_edit = Some(template.id.clone()); - } - if self.active_tab.is_some() - && ui - .button( - egui::RichText::new("Apply") - .color(egui::Color32::WHITE), - ) - .clicked() - { - template_to_apply = Some(template.clone()); - } - }, - ); - }); - }); - ui.add_space(4.0); + ui.add_space(4.0); + } + }); } }); @@ -418,6 +507,20 @@ impl UltraLogApp { ui.label(" ln, log, exp - Logarithms, exponential"); ui.label(" min, max - Minimum, maximum"); ui.label(" floor, ceil - Rounding"); + + ui.add_space(8.0); + ui.label(egui::RichText::new("Statistics (for anomaly detection):").strong()); + ui.label(" _mean_RPM - Mean of entire RPM channel"); + ui.label(" _stdev_RPM - Standard deviation of RPM"); + ui.label(" _min_RPM - Minimum value of RPM"); + ui.label(" _max_RPM - Maximum value of RPM"); + ui.label(" _range_RPM - Range (max - min) of RPM"); + ui.label(""); + ui.label( + egui::RichText::new("Z-score example: (RPM - _mean_RPM) / _stdev_RPM") + .small() + .color(egui::Color32::LIGHT_GREEN), + ); }); }); @@ -449,17 +552,43 @@ impl UltraLogApp { } }; - // Evaluate the formula - let cached_data = match evaluate_all_records( - &template.formula, - &bindings, - &file.log.data, - &file.log.times, - ) { - Ok(data) => Some(data), - Err(e) => { - self.show_toast_error(&format!("Evaluation failed: {}", e)); - return; + // Check if formula uses statistical variables (for z-score anomaly detection) + let needs_statistics = template.formula.contains("_mean_") + || template.formula.contains("_stdev_") + || template.formula.contains("_min_") + || template.formula.contains("_max_") + || template.formula.contains("_range_"); + + // Evaluate the formula (with or without statistics) + let cached_data = if needs_statistics { + // Compute statistics for all channels + let statistics = compute_all_channel_statistics(&available_channels, &file.log.data); + + match evaluate_all_records_with_stats( + &template.formula, + &bindings, + &file.log.data, + &file.log.times, + Some(&statistics), + ) { + Ok(data) => Some(data), + Err(e) => { + self.show_toast_error(&format!("Evaluation failed: {}", e)); + return; + } + } + } else { + match evaluate_all_records( + &template.formula, + &bindings, + &file.log.data, + &file.log.times, + ) { + Ok(data) => Some(data), + Err(e) => { + self.show_toast_error(&format!("Evaluation failed: {}", e)); + return; + } } }; diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 2e2c6ae..dd8e7f0 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -438,6 +438,13 @@ impl UltraLogApp { self.show_computed_channels_manager = true; ui.close(); } + + ui.separator(); + + if ui.button("📊 Analysis Tools...").clicked() { + self.show_analysis_panel = true; + ui.close(); + } }); ui.menu_button("Help", |ui| { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f277bb9..a55d2f4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,7 +14,9 @@ //! - `scatter_plot` - Scatter plot visualization view //! - `tab_bar` - Chrome-style tabs for managing multiple log files //! - `update_dialog` - Auto-update dialog window +//! - `analysis_panel` - Signal analysis tools window +pub mod analysis_panel; pub mod channels; pub mod chart; pub mod computed_channels_manager; diff --git a/src/ui/toast.rs b/src/ui/toast.rs index 53dd4ea..a52f0c3 100644 --- a/src/ui/toast.rs +++ b/src/ui/toast.rs @@ -34,6 +34,9 @@ impl UltraLogApp { color: egui::Color32::from_black_alpha(60), }) .show(ui, |ui| { + // Set min/max width for proper text wrapping + ui.set_min_width(200.0); + ui.set_max_width(400.0); ui.label( egui::RichText::new(message) .color(egui::Color32::from_rgb( diff --git a/tests/core/normalize_tests.rs b/tests/core/normalize_tests.rs index 924fc42..b8da0db 100644 --- a/tests/core/normalize_tests.rs +++ b/tests/core/normalize_tests.rs @@ -19,12 +19,29 @@ use ultralog::normalize::{ #[test] fn test_normalize_afr_variants() { + // Generic/overall AFR readings normalize to "AFR" assert_eq!(normalize_channel_name("Act_AFR"), "AFR"); assert_eq!(normalize_channel_name("R_EGO"), "AFR"); assert_eq!(normalize_channel_name("Air Fuel Ratio"), "AFR"); assert_eq!(normalize_channel_name("AFR"), "AFR"); - assert_eq!(normalize_channel_name("AFR1"), "AFR"); - assert_eq!(normalize_channel_name("WB2 AFR 1"), "AFR"); + assert_eq!(normalize_channel_name("Wideband O2"), "AFR"); + assert_eq!(normalize_channel_name("Wideband O2 Overall"), "AFR"); +} + +#[test] +fn test_normalize_afr_numbered_channels() { + // Numbered AFR/wideband channels normalize to distinct names + assert_eq!(normalize_channel_name("AFR1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("AFR 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("WB2 AFR 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband O2 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband 1"), "AFR Channel 1"); + + assert_eq!(normalize_channel_name("AFR2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("AFR 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("WB2 AFR 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("Wideband O2 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("Wideband 2"), "AFR Channel 2"); } #[test] From c054b573b3d437984f1b3f3f752f920632deb4d1 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Tue, 30 Dec 2025 21:59:31 -0500 Subject: [PATCH 03/13] Adds UI font scaling to make the app more accessible (#29) Signed-off-by: Cole Gentry --- src/app.rs | 11 +++++- src/state.rs | 26 +++++++++++++ src/ui/channels.rs | 59 ++++++++++++++++++++-------- src/ui/chart.rs | 2 +- src/ui/histogram.rs | 86 +++++++++++++++++++++++++---------------- src/ui/menu.rs | 84 ++++++++++++++++++++++++++++++---------- src/ui/scatter_plot.rs | 27 ++++++++----- src/ui/sidebar.rs | 49 ++++++++++++++++------- src/ui/tab_bar.rs | 6 ++- src/ui/timeline.rs | 43 ++++++++++++++------- src/ui/toast.rs | 2 +- src/ui/tool_switcher.rs | 2 +- src/ui/update_dialog.rs | 8 ++-- 13 files changed, 287 insertions(+), 118 deletions(-) diff --git a/src/app.rs b/src/app.rs index a41353c..5c6efd4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; use crate::parsers::{Aim, EcuMaster, EcuType, Haltech, Link, Parseable, RomRaider, Speeduino}; use crate::state::{ - ActiveTool, CacheKey, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, + ActiveTool, CacheKey, FontScale, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, COLORBLIND_COLORS, MAX_CHANNELS, }; @@ -74,6 +74,8 @@ pub struct UltraLogApp { // === Unit Preferences === /// User preferences for display units pub(crate) unit_preferences: UnitPreferences, + /// User preference for UI font size scaling + pub(crate) font_scale: FontScale, // === Custom Field Normalization === /// Custom user-defined field name mappings (source name -> normalized name) pub(crate) custom_normalizations: HashMap, @@ -151,6 +153,7 @@ impl Default for UltraLogApp { field_normalization: true, // Enabled by default for better readability initial_view_seconds: 60.0, // Start with 60 second view unit_preferences: UnitPreferences::default(), + font_scale: FontScale::default(), custom_normalizations: HashMap::new(), show_normalization_editor: false, norm_editor_extend_source: String::new(), @@ -230,6 +233,12 @@ impl UltraLogApp { palette[color_index % palette.len()] } + /// Get a scaled font size based on user's font scale preference + #[inline] + pub fn scaled_font(&self, base_size: f32) -> f32 { + (base_size * self.font_scale.multiplier()).round() + } + // ======================================================================== // File Loading // ======================================================================== diff --git a/src/state.rs b/src/state.rs index 9ecdd46..f9fdb23 100644 --- a/src/state.rs +++ b/src/state.rs @@ -195,6 +195,32 @@ impl ActiveTool { } } +/// Font scale preference for UI elements +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +pub enum FontScale { + /// Smaller fonts (0.85x) + Small, + /// Default size (1.0x) + #[default] + Medium, + /// Larger fonts (1.2x) + Large, + /// Extra large fonts (1.4x) + ExtraLarge, +} + +impl FontScale { + /// Get the multiplier for this font scale + pub fn multiplier(&self) -> f32 { + match self { + FontScale::Small => 0.85, + FontScale::Medium => 1.0, + FontScale::Large => 1.2, + FontScale::ExtraLarge => 1.4, + } + } +} + /// A selected point on a heatmap #[derive(Clone, Default)] pub struct SelectedHeatmapPoint { diff --git a/src/ui/channels.rs b/src/ui/channels.rs index f76d196..2fde154 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -9,7 +9,12 @@ use crate::state::MAX_CHANNELS; impl UltraLogApp { /// Render channel selection panel - fills available space pub fn render_channel_selection(&mut self, ui: &mut egui::Ui) { - ui.heading("Channels"); + // Pre-compute scaled font sizes + let font_14 = self.scaled_font(14.0); + let font_16 = self.scaled_font(16.0); + let font_18 = self.scaled_font(18.0); + + ui.label(egui::RichText::new("Channels").heading().size(font_18)); ui.separator(); // Get active tab info @@ -31,7 +36,7 @@ impl UltraLogApp { // Computed Channels button if ui - .button("+ Computed Channels") + .button(egui::RichText::new("+ Computed Channels").size(font_14)) .on_hover_text("Create virtual channels from mathematical formulas") .clicked() { @@ -44,7 +49,7 @@ impl UltraLogApp { let mut search_text = current_search; let mut search_changed = false; ui.horizontal(|ui| { - ui.label("Search:"); + ui.label(egui::RichText::new("Search:").size(font_14)); let response = ui .add(egui::TextEdit::singleline(&mut search_text).desired_width(f32::INFINITY)); search_changed = response.changed(); @@ -58,10 +63,13 @@ impl UltraLogApp { ui.add_space(5.0); // Channel count - ui.label(format!( - "Selected: {} / {} | Total: {}", - selected_count, MAX_CHANNELS, channel_count - )); + ui.label( + egui::RichText::new(format!( + "Selected: {} / {} | Total: {}", + selected_count, MAX_CHANNELS, channel_count + )) + .size(font_14), + ); ui.separator(); @@ -173,7 +181,8 @@ impl UltraLogApp { "📊 Channels with Data ({})", channels_with.len() )) - .strong(), + .strong() + .size(font_16), ) .default_open(true) .show(ui, |ui| { @@ -203,7 +212,8 @@ impl UltraLogApp { "📭 Empty Channels ({})", channels_without.len() )) - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_16), ) .default_open(false) // Collapsed by default .show(ui, |ui| { @@ -234,7 +244,8 @@ impl UltraLogApp { ui.label( egui::RichText::new("Select a file to view channels") .italics() - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_16), ); }); } @@ -242,7 +253,17 @@ impl UltraLogApp { /// Render selected channel cards pub fn render_selected_channels(&mut self, ui: &mut egui::Ui) { - ui.heading("Selected Channels"); + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + let font_16 = self.scaled_font(16.0); + let font_18 = self.scaled_font(18.0); + + ui.label( + egui::RichText::new("Selected Channels") + .heading() + .size(font_18), + ); ui.separator(); let use_normalization = self.field_normalization; @@ -391,7 +412,8 @@ impl UltraLogApp { ui.label( egui::RichText::new(&card.display_name) .strong() - .color(card.color), + .color(card.color) + .size(font_14), ); let close_btn = ui.small_button("x"); if close_btn.clicked() { @@ -408,11 +430,12 @@ impl UltraLogApp { ui.label( egui::RichText::new("Min:") .color(egui::Color32::GRAY) - .small(), + .size(font_12), ); ui.label( egui::RichText::new(min_str) - .color(egui::Color32::LIGHT_GRAY), + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), ); if let (Some(record), Some(time)) = (card.min_record, card.min_time) @@ -438,11 +461,12 @@ impl UltraLogApp { ui.label( egui::RichText::new("Max:") .color(egui::Color32::GRAY) - .small(), + .size(font_12), ); ui.label( egui::RichText::new(max_str) - .color(egui::Color32::LIGHT_GRAY), + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), ); if let (Some(record), Some(time)) = (card.max_record, card.max_time) @@ -488,7 +512,8 @@ impl UltraLogApp { ui.label( egui::RichText::new("Click channels to add them to the chart") .italics() - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_16), ); } } diff --git a/src/ui/chart.rs b/src/ui/chart.rs index 10c3527..7d98d20 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -17,7 +17,7 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new("Select channels to display chart") - .size(20.0) + .size(self.scaled_font(20.0)) .color(egui::Color32::GRAY), ); }); diff --git a/src/ui/histogram.rs b/src/ui/histogram.rs index b7a7a6b..a03a61b 100644 --- a/src/ui/histogram.rs +++ b/src/ui/histogram.rs @@ -90,7 +90,7 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new("Load a log file to use histogram") - .size(20.0) + .size(self.scaled_font(20.0)) .color(egui::Color32::GRAY), ); }); @@ -151,9 +151,13 @@ impl UltraLogApp { let mut new_mode: Option = None; let mut new_grid_size: Option = None; + // Pre-compute scaled font sizes + let font_14 = self.scaled_font(14.0); + let font_15 = self.scaled_font(15.0); + ui.horizontal(|ui| { // X Axis selector - ui.label(egui::RichText::new("X Axis:").size(15.0)); + ui.label(egui::RichText::new("X Axis:").size(font_15)); egui::ComboBox::from_id_salt("histogram_x") .selected_text( egui::RichText::new( @@ -161,7 +165,7 @@ impl UltraLogApp { .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) .unwrap_or("Select..."), ) - .size(14.0), + .size(font_14), ) .width(160.0) .show_ui(ui, |ui| { @@ -169,7 +173,7 @@ impl UltraLogApp { if ui .selectable_label( current_x == Some(*idx), - egui::RichText::new(name).size(14.0), + egui::RichText::new(name).size(font_14), ) .clicked() { @@ -181,7 +185,7 @@ impl UltraLogApp { ui.add_space(16.0); // Y Axis selector - ui.label(egui::RichText::new("Y Axis:").size(15.0)); + ui.label(egui::RichText::new("Y Axis:").size(font_15)); egui::ComboBox::from_id_salt("histogram_y") .selected_text( egui::RichText::new( @@ -189,7 +193,7 @@ impl UltraLogApp { .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) .unwrap_or("Select..."), ) - .size(14.0), + .size(font_14), ) .width(160.0) .show_ui(ui, |ui| { @@ -197,7 +201,7 @@ impl UltraLogApp { if ui .selectable_label( current_y == Some(*idx), - egui::RichText::new(name).size(14.0), + egui::RichText::new(name).size(font_14), ) .clicked() { @@ -211,7 +215,7 @@ impl UltraLogApp { // Z Axis selector (only enabled in AverageZ mode) let z_enabled = current_mode == HistogramMode::AverageZ; ui.add_enabled_ui(z_enabled, |ui| { - ui.label(egui::RichText::new("Z Axis:").size(15.0)); + ui.label(egui::RichText::new("Z Axis:").size(font_15)); egui::ComboBox::from_id_salt("histogram_z") .selected_text( egui::RichText::new( @@ -219,7 +223,7 @@ impl UltraLogApp { .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) .unwrap_or("Select..."), ) - .size(14.0), + .size(font_14), ) .width(160.0) .show_ui(ui, |ui| { @@ -227,7 +231,7 @@ impl UltraLogApp { if ui .selectable_label( current_z == Some(*idx), - egui::RichText::new(name).size(14.0), + egui::RichText::new(name).size(font_14), ) .clicked() { @@ -240,9 +244,9 @@ impl UltraLogApp { ui.add_space(20.0); // Grid size selector - ui.label(egui::RichText::new("Grid:").size(15.0)); + ui.label(egui::RichText::new("Grid:").size(font_15)); egui::ComboBox::from_id_salt("histogram_grid_size") - .selected_text(egui::RichText::new(current_grid_size.name()).size(14.0)) + .selected_text(egui::RichText::new(current_grid_size.name()).size(font_14)) .width(80.0) .show_ui(ui, |ui| { let sizes = [ @@ -254,7 +258,7 @@ impl UltraLogApp { if ui .selectable_label( current_grid_size == size, - egui::RichText::new(size.name()).size(14.0), + egui::RichText::new(size.name()).size(font_14), ) .clicked() { @@ -266,11 +270,11 @@ impl UltraLogApp { ui.add_space(20.0); // Mode toggle - ui.label(egui::RichText::new("Mode:").size(15.0)); + ui.label(egui::RichText::new("Mode:").size(font_15)); if ui .selectable_label( current_mode == HistogramMode::AverageZ, - egui::RichText::new("Average Z").size(14.0), + egui::RichText::new("Average Z").size(font_14), ) .clicked() { @@ -279,7 +283,7 @@ impl UltraLogApp { if ui .selectable_label( current_mode == HistogramMode::HitCount, - egui::RichText::new("Hit Count").size(14.0), + egui::RichText::new("Hit Count").size(font_14), ) .clicked() { @@ -322,6 +326,12 @@ impl UltraLogApp { let mode = config.mode; let grid_size = config.grid_size.size(); + // Pre-compute scaled font sizes for use in closures + let font_10 = self.scaled_font(10.0); + let font_12 = self.scaled_font(12.0); + let font_13 = self.scaled_font(13.0); + let font_16 = self.scaled_font(16.0); + // Check valid axis selections let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { (Some(x), Some(y)) => (x, y), @@ -332,7 +342,7 @@ impl UltraLogApp { rect.center(), egui::Align2::CENTER_CENTER, "Select X and Y axes", - egui::FontId::proportional(16.0), + egui::FontId::proportional(font_16), egui::Color32::GRAY, ); return; @@ -350,7 +360,7 @@ impl UltraLogApp { rect.center(), egui::Align2::CENTER_CENTER, "Select Z axis for Average mode", - egui::FontId::proportional(16.0), + egui::FontId::proportional(font_16), egui::Color32::GRAY, ); return; @@ -499,19 +509,20 @@ impl UltraLogApp { // Choose text color for AAA contrast compliance let text_color = get_aaa_text_color(color); - let font_size = if grid_size <= 16 { + let base_font_size = if grid_size <= 16 { 11.0 } else if grid_size <= 32 { 9.0 } else { 7.0 }; + let cell_font_size = self.scaled_font(base_font_size); painter.text( cell_rect.center(), egui::Align2::CENTER_CENTER, text, - egui::FontId::proportional(font_size), + egui::FontId::proportional(cell_font_size), text_color, ); } @@ -557,7 +568,7 @@ impl UltraLogApp { egui::pos2(plot_rect.left() - 8.0, y_pos), egui::Align2::RIGHT_CENTER, format!("{:.1}", value), - egui::FontId::proportional(10.0), + egui::FontId::proportional(font_10), text_color, ); } @@ -569,7 +580,7 @@ impl UltraLogApp { egui::pos2(y_title_x, y_title_y), egui::Align2::CENTER_CENTER, &y_channel_name, - egui::FontId::proportional(13.0), + egui::FontId::proportional(font_13), axis_title_color, ); @@ -582,7 +593,7 @@ impl UltraLogApp { egui::pos2(x_pos, plot_rect.bottom() + 5.0), egui::Align2::CENTER_TOP, format!("{:.0}", value), - egui::FontId::proportional(10.0), + egui::FontId::proportional(font_10), text_color, ); } @@ -594,7 +605,7 @@ impl UltraLogApp { egui::pos2(x_title_x, x_title_y), egui::Align2::CENTER_CENTER, &x_channel_name, - egui::FontId::proportional(13.0), + egui::FontId::proportional(font_13), axis_title_color, ); @@ -727,7 +738,7 @@ impl UltraLogApp { egui::pos2(plot_rect.right() - 10.0, plot_rect.top() + 15.0), egui::Align2::RIGHT_TOP, tooltip_text, - egui::FontId::proportional(12.0), + egui::FontId::proportional(font_12), egui::Color32::WHITE, ); } @@ -844,6 +855,8 @@ impl UltraLogApp { mode: HistogramMode, grid_size: usize, ) { + let font_13 = self.scaled_font(13.0); + ui.horizontal(|ui| { ui.add_space(4.0); @@ -860,7 +873,7 @@ impl UltraLogApp { }; ui.label( egui::RichText::new(label) - .size(13.0) + .size(font_13) .color(egui::Color32::WHITE), ); @@ -894,7 +907,7 @@ impl UltraLogApp { }; ui.label( egui::RichText::new(range_text) - .size(13.0) + .size(font_13) .color(egui::Color32::WHITE), ); }); @@ -911,13 +924,13 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("Grid: {}x{}", grid_size, grid_size)) - .size(13.0) + .size(font_13) .color(egui::Color32::WHITE), ); ui.add_space(12.0); ui.label( egui::RichText::new(format!("Total Points: {}", total_points)) - .size(13.0) + .size(font_13) .color(egui::Color32::WHITE), ); }); @@ -933,13 +946,18 @@ impl UltraLogApp { let selected = &self.tabs[tab_idx].histogram_state.config.selected_cell; + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_13 = self.scaled_font(13.0); + let font_14 = self.scaled_font(14.0); + ui.add_space(10.0); ui.separator(); ui.add_space(5.0); ui.label( egui::RichText::new("Cell Statistics") - .size(14.0) + .size(font_14) .strong() .color(egui::Color32::WHITE), ); @@ -956,7 +974,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("Cell [{}, {}]", cell.x_bin, cell.y_bin)) - .size(13.0) + .size(font_13) .color(SELECTED_CELL_COLOR), ); }); @@ -985,7 +1003,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("{}:", label)) - .size(12.0) + .size(font_12) .color(egui::Color32::from_rgb(180, 180, 180)), ); ui.with_layout( @@ -993,7 +1011,7 @@ impl UltraLogApp { |ui| { ui.label( egui::RichText::new(&value) - .size(12.0) + .size(font_12) .color(egui::Color32::WHITE), ); }, @@ -1010,7 +1028,7 @@ impl UltraLogApp { } else { ui.label( egui::RichText::new("Click a cell to view statistics") - .size(12.0) + .size(font_12) .italics() .color(egui::Color32::GRAY), ); diff --git a/src/ui/menu.rs b/src/ui/menu.rs index dd8e7f0..ab5bda3 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -4,7 +4,7 @@ use eframe::egui; use crate::analytics; use crate::app::UltraLogApp; -use crate::state::{ActiveTool, LoadingState}; +use crate::state::{ActiveTool, FontScale, LoadingState}; use crate::units::{ AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, TemperatureUnit, VolumeUnit, @@ -13,11 +13,15 @@ use crate::units::{ impl UltraLogApp { /// Render the application menu bar pub fn render_menu_bar(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font sizes for use in closures + let font_14 = self.scaled_font(14.0); + let font_15 = self.scaled_font(15.0); + egui::MenuBar::new().ui(ui, |ui| { // Increase font size for menu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(15.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_15)); // File menu ui.menu_button("File", |ui| { @@ -26,10 +30,10 @@ impl UltraLogApp { // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); let is_loading = matches!(self.loading_state, LoadingState::Loading(_)); @@ -68,7 +72,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if self.active_tool == ActiveTool::Histogram && has_histogram_data { // Histogram export options @@ -98,10 +102,10 @@ impl UltraLogApp { // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); // Cursor Tracking toggle if ui @@ -153,6 +157,44 @@ impl UltraLogApp { { ui.close(); } + + ui.separator(); + + // Font Size submenu + ui.menu_button("🔠 Font Size", |ui| { + ui.style_mut() + .text_styles + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); + + if ui + .radio_value(&mut self.font_scale, FontScale::Small, "Small (85%)") + .clicked() + { + ui.close(); + } + if ui + .radio_value(&mut self.font_scale, FontScale::Medium, "Medium (100%)") + .clicked() + { + ui.close(); + } + if ui + .radio_value(&mut self.font_scale, FontScale::Large, "Large (120%)") + .clicked() + { + ui.close(); + } + if ui + .radio_value( + &mut self.font_scale, + FontScale::ExtraLarge, + "Extra Large (140%)", + ) + .clicked() + { + ui.close(); + } + }); }); // Units menu @@ -162,17 +204,17 @@ impl UltraLogApp { // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); // Temperature submenu ui.menu_button("°C Temperature", |ui| { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.temperature, @@ -210,7 +252,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.pressure, @@ -248,7 +290,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.speed, @@ -276,7 +318,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.distance, @@ -306,7 +348,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.fuel_economy, @@ -344,7 +386,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.volume, @@ -372,7 +414,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.flow, @@ -398,7 +440,7 @@ impl UltraLogApp { // Increase font size for submenu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if ui .radio_value( &mut self.unit_preferences.acceleration, @@ -429,10 +471,10 @@ impl UltraLogApp { // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); if ui.button("ƒ(x) Computed Channels...").clicked() { self.show_computed_channels_manager = true; @@ -453,10 +495,10 @@ impl UltraLogApp { // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); if ui.button("📖 Documentation").clicked() { let _ = open::that("https://github.com/SomethingNew71/UltraLog/wiki"); diff --git a/src/ui/scatter_plot.rs b/src/ui/scatter_plot.rs index f06f3c4..0e425c2 100644 --- a/src/ui/scatter_plot.rs +++ b/src/ui/scatter_plot.rs @@ -45,7 +45,7 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new("Load a log file to use scatter plots") - .size(20.0) + .size(self.scaled_font(20.0)) .color(egui::Color32::GRAY), ); }); @@ -95,7 +95,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(&title) - .size(16.0) + .size(self.scaled_font(16.0)) .strong() .color(egui::Color32::WHITE), ); @@ -266,6 +266,11 @@ impl UltraLogApp { let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); + // Pre-compute scaled font sizes + let font_10 = self.scaled_font(10.0); + let font_11 = self.scaled_font(11.0); + let font_16 = self.scaled_font(16.0); + // Check if we have valid axis selections let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { (Some(x), Some(y)) => (x, y), @@ -277,7 +282,7 @@ impl UltraLogApp { rect.center(), egui::Align2::CENTER_CENTER, "Select X and Y axes", - egui::FontId::proportional(16.0), + egui::FontId::proportional(font_16), egui::Color32::GRAY, ); return; @@ -396,7 +401,7 @@ impl UltraLogApp { egui::pos2(plot_rect.left() - 5.0, y_pos), egui::Align2::RIGHT_CENTER, format!("{:.1}", value), - egui::FontId::proportional(10.0), + egui::FontId::proportional(font_10), text_color, ); @@ -421,7 +426,7 @@ impl UltraLogApp { egui::pos2(x_pos, plot_rect.bottom() + 5.0), egui::Align2::CENTER_TOP, format!("{:.0}", value), - egui::FontId::proportional(10.0), + egui::FontId::proportional(font_10), text_color, ); @@ -510,7 +515,7 @@ impl UltraLogApp { egui::pos2(plot_rect.right() - 10.0, plot_rect.top() + 15.0), egui::Align2::RIGHT_TOP, tooltip_text, - egui::FontId::proportional(11.0), + egui::FontId::proportional(font_11), egui::Color32::WHITE, ); @@ -571,6 +576,10 @@ impl UltraLogApp { return; }; + // Pre-compute scaled font sizes + let font_10 = self.scaled_font(10.0); + let font_11 = self.scaled_font(11.0); + // First, gather the data we need (immutable borrow) let config = if is_left { &self.tabs[tab_idx].scatter_plot_state.left @@ -631,7 +640,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new("Hits:") - .size(11.0) + .size(font_11) .color(egui::Color32::WHITE), ); @@ -660,7 +669,7 @@ impl UltraLogApp { ui.add_space(4.0); ui.label( egui::RichText::new(format!("0-{}", max_hits)) - .size(10.0) + .size(font_10) .color(egui::Color32::WHITE), ); }); @@ -697,7 +706,7 @@ impl UltraLogApp { selected.y_value, selected.hits )) - .size(11.0) + .size(font_11) .color(egui::Color32::WHITE), ); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index dea7f2d..82a553a 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -66,7 +66,7 @@ impl UltraLogApp { "{} | {} channels | {} points", ecu_name, channel_count, data_count )) - .size(12.0) + .size(self.scaled_font(12.0)) .color(egui::Color32::GRAY), ); }); @@ -98,7 +98,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("+ Add File") .color(egui::Color32::WHITE) - .size(14.0), + .size(self.scaled_font(14.0)), ); }); @@ -163,7 +163,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("Select a file") .color(egui::Color32::WHITE) - .size(14.0), + .size(self.scaled_font(14.0)), ); }); @@ -186,14 +186,18 @@ impl UltraLogApp { ui.add_space(12.0); - ui.label(egui::RichText::new("or").color(text_gray).size(12.0)); + ui.label( + egui::RichText::new("or") + .color(text_gray) + .size(self.scaled_font(12.0)), + ); ui.add_space(8.0); ui.label( egui::RichText::new("Drop file here") .color(egui::Color32::LIGHT_GRAY) - .size(13.0), + .size(self.scaled_font(13.0)), ); ui.add_space(12.0); @@ -201,7 +205,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("CSV • LOG • TXT • MLG") .color(text_gray) - .size(11.0), + .size(self.scaled_font(11.0)), ); }); }); @@ -209,6 +213,11 @@ impl UltraLogApp { /// Render view options at the bottom of the sidebar fn render_view_options(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + let font_18 = self.scaled_font(18.0); + ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { // Reverse order since we're bottom-up ui.add_space(10.0); @@ -232,16 +241,20 @@ impl UltraLogApp { .inner_margin(10.0) .show(ui, |ui| { // Cursor tracking checkbox - ui.checkbox(&mut self.cursor_tracking, "🎯 Cursor Tracking"); + ui.checkbox( + &mut self.cursor_tracking, + egui::RichText::new("🎯 Cursor Tracking").size(font_14), + ); ui.label( egui::RichText::new("Keep cursor centered while scrubbing") - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_12), ); // Window size slider (only show when cursor tracking is enabled) if self.cursor_tracking { ui.add_space(8.0); - ui.label("View Window:"); + ui.label(egui::RichText::new("View Window:").size(font_14)); ui.add( egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) .suffix("s") @@ -254,10 +267,14 @@ impl UltraLogApp { ui.add_space(4.0); // Color blind mode checkbox - ui.checkbox(&mut self.color_blind_mode, "👁 Color Blind Mode"); + ui.checkbox( + &mut self.color_blind_mode, + egui::RichText::new("👁 Color Blind Mode").size(font_14), + ); ui.label( egui::RichText::new("Use accessible color palette") - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_12), ); ui.add_space(8.0); @@ -266,7 +283,10 @@ impl UltraLogApp { // Field normalization checkbox with right-aligned Edit button ui.horizontal(|ui| { - ui.checkbox(&mut self.field_normalization, "📝 Field Normalization"); + ui.checkbox( + &mut self.field_normalization, + egui::RichText::new("📝 Field Normalization").size(font_14), + ); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -278,13 +298,14 @@ impl UltraLogApp { }); ui.label( egui::RichText::new("Standardize channel names across ECU types") - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_12), ); }); ui.add_space(5.0); ui.separator(); - ui.heading("View Options"); + ui.label(egui::RichText::new("View Options").heading().size(font_18)); } }); } diff --git a/src/ui/tab_bar.rs b/src/ui/tab_bar.rs index a507880..98c27a3 100644 --- a/src/ui/tab_bar.rs +++ b/src/ui/tab_bar.rs @@ -63,9 +63,10 @@ impl UltraLogApp { .show(ui, |ui| { ui.horizontal(|ui| { // Tab name (clickable) + let font_13 = self.scaled_font(13.0); let label_response = ui.add( egui::Label::new( - egui::RichText::new(name).color(text_color).size(13.0), + egui::RichText::new(name).color(text_color).size(font_13), ) .sense(egui::Sense::click()), ); @@ -80,11 +81,12 @@ impl UltraLogApp { ui.add_space(4.0); // Close button + let font_14 = self.scaled_font(14.0); let close_btn = ui.add( egui::Label::new( egui::RichText::new("×") .color(egui::Color32::from_rgb(150, 150, 150)) - .size(14.0), + .size(font_14), ) .sense(egui::Sense::click()), ); diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index 03051ba..dae566e 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -8,6 +8,9 @@ use crate::app::UltraLogApp; impl UltraLogApp { /// Render the timeline scrubber bar pub fn render_timeline_scrubber(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font size + let font_12 = self.scaled_font(12.0); + let Some((min_time, max_time)) = self.get_time_range() else { return; }; @@ -20,12 +23,15 @@ impl UltraLogApp { // Time labels row ui.horizontal(|ui| { ui.label( - egui::RichText::new(Self::format_time(min_time)).color(egui::Color32::LIGHT_GRAY), + egui::RichText::new(Self::format_time(min_time)) + .color(egui::Color32::LIGHT_GRAY) + .size(font_12), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( egui::RichText::new(Self::format_time(max_time)) - .color(egui::Color32::LIGHT_GRAY), + .color(egui::Color32::LIGHT_GRAY) + .size(font_12), ); }); }); @@ -63,19 +69,24 @@ impl UltraLogApp { /// Render the record/time indicator bar with playback controls pub fn render_record_indicator(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font size + let font_14 = self.scaled_font(14.0); + ui.horizontal(|ui| { // Playback controls let button_size = egui::vec2(28.0, 28.0); // Play/Pause button let play_text = if self.is_playing { "⏸" } else { "▶" }; - let play_button = egui::Button::new(egui::RichText::new(play_text).size(16.0).color( - if self.is_playing { - egui::Color32::from_rgb(253, 193, 73) // Amber when playing - } else { - egui::Color32::from_rgb(144, 238, 144) // Light green when paused - }, - )) + let play_button = egui::Button::new( + egui::RichText::new(play_text) + .size(self.scaled_font(16.0)) + .color(if self.is_playing { + egui::Color32::from_rgb(253, 193, 73) // Amber when playing + } else { + egui::Color32::from_rgb(144, 238, 144) // Light green when paused + }), + ) .min_size(button_size); if ui.add(play_button).clicked() { @@ -99,7 +110,7 @@ impl UltraLogApp { // Stop button (resets to beginning) let stop_button = egui::Button::new( egui::RichText::new("⏹") - .size(16.0) + .size(self.scaled_font(16.0)) .color(egui::Color32::from_rgb(191, 78, 48)), // Rust orange ) .min_size(button_size); @@ -118,7 +129,11 @@ impl UltraLogApp { ui.separator(); // Playback speed selector - ui.label(egui::RichText::new("Speed:").color(egui::Color32::GRAY)); + ui.label( + egui::RichText::new("Speed:") + .color(egui::Color32::GRAY) + .size(font_14), + ); let speed_options = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; egui::ComboBox::from_id_salt("playback_speed") @@ -137,7 +152,8 @@ impl UltraLogApp { ui.label( egui::RichText::new(format!("Time: {}", Self::format_time(time))) .strong() - .color(egui::Color32::from_rgb(0, 255, 255)), // Cyan to match cursor + .color(egui::Color32::from_rgb(0, 255, 255)) // Cyan to match cursor + .size(font_14), ); } @@ -155,7 +171,8 @@ impl UltraLogApp { record + 1, total_records )) - .color(egui::Color32::LIGHT_GRAY), + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), ); } } diff --git a/src/ui/toast.rs b/src/ui/toast.rs index a52f0c3..c0472d9 100644 --- a/src/ui/toast.rs +++ b/src/ui/toast.rs @@ -44,7 +44,7 @@ impl UltraLogApp { text_color[1], text_color[2], )) - .size(14.0), + .size(self.scaled_font(14.0)), ); }); }); diff --git a/src/ui/tool_switcher.rs b/src/ui/tool_switcher.rs index 90493f7..b036b55 100644 --- a/src/ui/tool_switcher.rs +++ b/src/ui/tool_switcher.rs @@ -48,7 +48,7 @@ impl UltraLogApp { let response = ui.add( egui::Button::new( egui::RichText::new(tool.name()) - .size(14.0) + .size(self.scaled_font(14.0)) .color(text_color), ) .fill(button_fill) diff --git a/src/ui/update_dialog.rs b/src/ui/update_dialog.rs index 23ee10d..4d14faa 100644 --- a/src/ui/update_dialog.rs +++ b/src/ui/update_dialog.rs @@ -63,7 +63,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("A new version is available!") - .size(18.0) + .size(self.scaled_font(18.0)) .strong(), ); @@ -133,7 +133,7 @@ impl UltraLogApp { ui.vertical_centered(|ui| { ui.add_space(20.0); - ui.label(egui::RichText::new("Downloading update...").size(16.0)); + ui.label(egui::RichText::new("Downloading update...").size(self.scaled_font(16.0))); ui.add_space(15.0); @@ -158,7 +158,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("Download complete!") - .size(16.0) + .size(self.scaled_font(16.0)) .color(egui::Color32::LIGHT_GREEN), ); @@ -224,7 +224,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("Update Error") - .size(16.0) + .size(self.scaled_font(16.0)) .color(egui::Color32::from_rgb(191, 78, 48)), ); From b38f7fedf379e6c1725de9ec4d94c96d55b08ddc Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Tue, 30 Dec 2025 22:48:44 -0500 Subject: [PATCH 04/13] Initial restructure of UI to make it more clear and easy to use Signed-off-by: Cole Gentry --- src/app.rs | 129 ++++++-- src/state.rs | 36 +++ src/ui/activity_bar.rs | 225 ++++++++++++++ src/ui/channels.rs | 172 ++++++----- src/ui/channels_panel.rs | 295 ++++++++++++++++++ src/ui/computed_channels_manager.rs | 4 +- src/ui/files_panel.rs | 282 +++++++++++++++++ src/ui/menu.rs | 447 +++++---------------------- src/ui/mod.rs | 30 +- src/ui/settings_panel.rs | 460 ++++++++++++++++++++++++++++ src/ui/side_panel.rs | 35 +++ src/ui/tools_panel.rs | 356 +++++++++++++++++++++ 12 files changed, 1980 insertions(+), 491 deletions(-) create mode 100644 src/ui/activity_bar.rs create mode 100644 src/ui/channels_panel.rs create mode 100644 src/ui/files_panel.rs create mode 100644 src/ui/settings_panel.rs create mode 100644 src/ui/side_panel.rs create mode 100644 src/ui/tools_panel.rs diff --git a/src/app.rs b/src/app.rs index 5c6efd4..765808b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,9 +16,9 @@ use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; use crate::parsers::{Aim, EcuMaster, EcuType, Haltech, Link, Parseable, RomRaider, Speeduino}; use crate::state::{ - ActiveTool, CacheKey, FontScale, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, - ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, COLORBLIND_COLORS, - MAX_CHANNELS, + ActivePanel, ActiveTool, CacheKey, FontScale, LoadResult, LoadedFile, LoadingState, + ScatterPlotConfig, ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, + COLORBLIND_COLORS, MAX_CHANNELS, }; use crate::units::UnitPreferences; use crate::updater::{DownloadResult, UpdateCheckResult, UpdateState}; @@ -92,6 +92,9 @@ pub struct UltraLogApp { // === Tool/View Selection === /// Currently active tool/view pub(crate) active_tool: ActiveTool, + // === Panel Selection === + /// Currently active side panel (activity bar selection) + pub(crate) active_panel: ActivePanel, // === Tab Management === /// Open tabs (one per log file being viewed) pub(crate) tabs: Vec, @@ -161,6 +164,7 @@ impl Default for UltraLogApp { norm_editor_custom_source: String::new(), norm_editor_custom_target: String::new(), active_tool: ActiveTool::default(), + active_panel: ActivePanel::default(), tabs: Vec::new(), active_tab: None, update_state: UpdateState::default(), @@ -1233,18 +1237,78 @@ impl UltraLogApp { /// Handle keyboard shortcuts fn handle_keyboard_shortcuts(&mut self, ctx: &egui::Context) { - // Only handle shortcuts when we have data loaded - if self.files.is_empty() || self.get_selected_channels().is_empty() { - return; - } - // Don't handle shortcuts when a text field or other widget has keyboard focus if ctx.memory(|m| m.focused().is_some()) { return; } - // Spacebar to toggle play/pause ctx.input(|i| { + let cmd = i.modifiers.command; + let shift = i.modifiers.shift; + + // ⌘O - Open file + if cmd && i.key_pressed(egui::Key::O) { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + return; + } + + // ⌘W - Close current tab + if cmd && i.key_pressed(egui::Key::W) { + if let Some(tab_idx) = self.active_tab { + self.close_tab(tab_idx); + } + return; + } + + // ⌘, - Open Settings panel + if cmd && i.key_pressed(egui::Key::Comma) { + self.active_panel = crate::state::ActivePanel::Settings; + return; + } + + // ⌘1/2/3 - Switch tool modes + if cmd && !shift { + if i.key_pressed(egui::Key::Num1) { + self.active_tool = crate::state::ActiveTool::LogViewer; + return; + } + if i.key_pressed(egui::Key::Num2) { + self.active_tool = crate::state::ActiveTool::ScatterPlot; + return; + } + if i.key_pressed(egui::Key::Num3) { + self.active_tool = crate::state::ActiveTool::Histogram; + return; + } + } + + // ⌘⇧F/C/T - Switch panels + if cmd && shift { + if i.key_pressed(egui::Key::F) { + self.active_panel = crate::state::ActivePanel::Files; + return; + } + if i.key_pressed(egui::Key::C) { + self.active_panel = crate::state::ActivePanel::Channels; + return; + } + if i.key_pressed(egui::Key::T) { + self.active_panel = crate::state::ActivePanel::Tools; + return; + } + } + + // Playback shortcuts (require file loaded with channels selected) + if self.files.is_empty() || self.get_selected_channels().is_empty() { + return; + } + + // Spacebar to toggle play/pause if i.key_pressed(egui::Key::Space) { self.is_playing = !self.is_playing; if self.is_playing { @@ -1347,41 +1411,38 @@ impl eframe::App for UltraLogApp { self.render_tool_switcher(ui); }); - // Panel background color (matches drop zone card) + // Panel background color let panel_bg = egui::Color32::from_rgb(45, 45, 45); let panel_frame = egui::Frame::NONE .fill(panel_bg) .inner_margin(egui::Margin::symmetric(10, 10)); - // Left sidebar panel (always visible) - egui::SidePanel::left("files_panel") - .default_width(200.0) + // Activity bar (far left, narrow icon strip) + let activity_bar_bg = egui::Color32::from_rgb(35, 35, 35); + let activity_bar_frame = egui::Frame::NONE + .fill(activity_bar_bg) + .inner_margin(egui::Margin::symmetric(4, 8)); + + egui::SidePanel::left("activity_bar") + .exact_width(crate::ui::activity_bar::ACTIVITY_BAR_WIDTH) + .resizable(false) + .frame(activity_bar_frame) + .show(ctx, |ui| { + self.render_activity_bar(ui); + }); + + // Side panel (context-sensitive based on activity bar selection) + egui::SidePanel::left("side_panel") + .default_width(crate::ui::side_panel::SIDE_PANEL_WIDTH) + .min_width(crate::ui::side_panel::SIDE_PANEL_MIN_WIDTH) .resizable(true) .frame(panel_frame) .show(ctx, |ui| { - self.render_sidebar(ui); + self.render_side_panel(ui); }); - // Right panel for channel selection (only in Log Viewer mode) - if self.active_tool == ActiveTool::LogViewer { - egui::SidePanel::right("channels_panel") - .default_width(300.0) - .min_width(200.0) - .resizable(true) - .frame(panel_frame) - .show(ctx, |ui| { - self.render_channel_selection(ui); - }); - } - - // Bottom panel for timeline scrubber (Log Viewer and Histogram modes) - let show_timeline = match self.active_tool { - ActiveTool::LogViewer => { - self.get_time_range().is_some() && !self.get_selected_channels().is_empty() - } - ActiveTool::Histogram => self.get_time_range().is_some(), - ActiveTool::ScatterPlot => false, - }; + // Bottom panel for timeline scrubber (always visible when file loaded) + let show_timeline = self.get_time_range().is_some(); if show_timeline { egui::TopBottomPanel::bottom("timeline_panel") diff --git a/src/state.rs b/src/state.rs index f9fdb23..e8fa623 100644 --- a/src/state.rs +++ b/src/state.rs @@ -195,6 +195,42 @@ impl ActiveTool { } } +/// The currently active side panel in the activity bar +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum ActivePanel { + /// Files panel - file management, loading, file list + #[default] + Files, + /// Channels panel - channel selection and selected channels + Channels, + /// Tools panel - analysis tools, computed channels, export + Tools, + /// Settings panel - all preferences consolidated + Settings, +} + +impl ActivePanel { + /// Get the display name for this panel + pub fn name(&self) -> &'static str { + match self { + ActivePanel::Files => "Files", + ActivePanel::Channels => "Channels", + ActivePanel::Tools => "Tools", + ActivePanel::Settings => "Settings", + } + } + + /// Get the icon character for this panel (using Unicode symbols) + pub fn icon(&self) -> &'static str { + match self { + ActivePanel::Files => "\u{1F4C1}", // Folder icon + ActivePanel::Channels => "\u{1F4CA}", // Chart icon + ActivePanel::Tools => "\u{1F527}", // Wrench icon + ActivePanel::Settings => "\u{2699}", // Gear icon + } + } +} + /// Font scale preference for UI elements #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum FontScale { diff --git a/src/ui/activity_bar.rs b/src/ui/activity_bar.rs new file mode 100644 index 0000000..b4b7c94 --- /dev/null +++ b/src/ui/activity_bar.rs @@ -0,0 +1,225 @@ +//! Activity bar component - VS Code-style vertical icon strip for panel navigation. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActivePanel; + +/// Width of the activity bar in pixels +pub const ACTIVITY_BAR_WIDTH: f32 = 48.0; + +/// Size of icons in the activity bar +const ICON_SIZE: f32 = 24.0; + +/// Padding around icons (reserved for future use) +#[allow(dead_code)] +const ICON_PADDING: f32 = 12.0; + +impl UltraLogApp { + /// Render the activity bar (vertical icon strip on the far left) + pub fn render_activity_bar(&mut self, ui: &mut egui::Ui) { + ui.vertical(|ui| { + ui.add_space(8.0); + + // Render each panel icon + for panel in [ + ActivePanel::Files, + ActivePanel::Channels, + ActivePanel::Tools, + ActivePanel::Settings, + ] { + let is_selected = self.active_panel == panel; + self.render_activity_icon(ui, panel, is_selected); + ui.add_space(4.0); + } + }); + } + + /// Render a single activity bar icon button + fn render_activity_icon(&mut self, ui: &mut egui::Ui, panel: ActivePanel, is_selected: bool) { + let button_size = egui::vec2(ACTIVITY_BAR_WIDTH - 8.0, ACTIVITY_BAR_WIDTH - 8.0); + + // Colors + let bg_color = if is_selected { + egui::Color32::from_rgb(60, 60, 60) + } else { + egui::Color32::TRANSPARENT + }; + + let icon_color = if is_selected { + egui::Color32::WHITE + } else { + egui::Color32::from_rgb(150, 150, 150) + }; + + let hover_bg = egui::Color32::from_rgb(50, 50, 50); + + // Selected indicator bar on the left + let indicator_color = egui::Color32::from_rgb(113, 120, 78); // Olive green accent + + let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click()); + + if response.clicked() { + self.active_panel = panel; + } + + let is_hovered = response.hovered(); + + // Draw background + let final_bg = if is_hovered && !is_selected { + hover_bg + } else { + bg_color + }; + + if final_bg != egui::Color32::TRANSPARENT { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(4), final_bg); + } + + // Draw selection indicator bar on left edge + if is_selected { + let indicator_rect = + egui::Rect::from_min_size(rect.left_top(), egui::vec2(3.0, rect.height())); + ui.painter() + .rect_filled(indicator_rect, egui::CornerRadius::ZERO, indicator_color); + } + + // Draw the icon + let center = rect.center(); + self.draw_panel_icon(ui, center, ICON_SIZE, icon_color, panel); + + // Tooltip on hover + if is_hovered { + response.on_hover_text(panel.name()); + } + } + + /// Draw the icon for a specific panel type + fn draw_panel_icon( + &self, + ui: &egui::Ui, + center: egui::Pos2, + size: f32, + color: egui::Color32, + panel: ActivePanel, + ) { + let painter = ui.painter(); + let half = size / 2.0; + + match panel { + ActivePanel::Files => { + // Folder icon + let folder_width = size * 0.9; + let folder_height = size * 0.7; + let tab_width = folder_width * 0.4; + let tab_height = folder_height * 0.15; + + // Main folder body + let body_rect = egui::Rect::from_center_size( + egui::pos2(center.x, center.y + tab_height / 2.0), + egui::vec2(folder_width, folder_height - tab_height), + ); + painter.rect_stroke( + body_rect, + egui::CornerRadius::same(2), + egui::Stroke::new(1.5, color), + egui::StrokeKind::Outside, + ); + + // Folder tab + let tab_rect = egui::Rect::from_min_size( + egui::pos2(body_rect.left() + 2.0, body_rect.top() - tab_height), + egui::vec2(tab_width, tab_height + 2.0), + ); + painter.rect_filled(tab_rect, egui::CornerRadius::same(1), color); + } + ActivePanel::Channels => { + // Bar chart icon + let bar_width = size * 0.15; + let spacing = size * 0.22; + let base_y = center.y + half * 0.6; + + // Three bars of different heights + let heights = [size * 0.5, size * 0.8, size * 0.35]; + let start_x = center.x - spacing; + + for (i, &height) in heights.iter().enumerate() { + let x = start_x + (i as f32) * spacing; + let bar_rect = egui::Rect::from_min_max( + egui::pos2(x - bar_width / 2.0, base_y - height), + egui::pos2(x + bar_width / 2.0, base_y), + ); + painter.rect_filled(bar_rect, egui::CornerRadius::same(1), color); + } + } + ActivePanel::Tools => { + // Wrench icon + let stroke = egui::Stroke::new(2.0, color); + + // Handle (diagonal line) + let handle_start = egui::pos2(center.x - half * 0.5, center.y + half * 0.5); + let handle_end = egui::pos2(center.x + half * 0.1, center.y - half * 0.1); + painter.line_segment([handle_start, handle_end], stroke); + + // Wrench head (arc-like shape using lines) + let head_center = egui::pos2(center.x + half * 0.25, center.y - half * 0.25); + let head_radius = half * 0.45; + + // Draw wrench head as a partial circle with opening + let segments = 8; + let start_angle = std::f32::consts::PI * 0.25; + let end_angle = std::f32::consts::PI * 1.75; + let angle_step = (end_angle - start_angle) / segments as f32; + + for i in 0..segments { + let a1 = start_angle + (i as f32) * angle_step; + let a2 = start_angle + ((i + 1) as f32) * angle_step; + let p1 = egui::pos2( + head_center.x + head_radius * a1.cos(), + head_center.y + head_radius * a1.sin(), + ); + let p2 = egui::pos2( + head_center.x + head_radius * a2.cos(), + head_center.y + head_radius * a2.sin(), + ); + painter.line_segment([p1, p2], stroke); + } + } + ActivePanel::Settings => { + // Gear icon + let outer_radius = half * 0.85; + let inner_radius = half * 0.45; + let teeth = 8; + let tooth_depth = half * 0.2; + + // Draw gear teeth + for i in 0..teeth { + let angle = (i as f32) * std::f32::consts::TAU / teeth as f32; + let next_angle = ((i as f32) + 0.5) * std::f32::consts::TAU / teeth as f32; + + let outer_point = egui::pos2( + center.x + outer_radius * angle.cos(), + center.y + outer_radius * angle.sin(), + ); + let inner_point = egui::pos2( + center.x + (outer_radius - tooth_depth) * next_angle.cos(), + center.y + (outer_radius - tooth_depth) * next_angle.sin(), + ); + + painter.line_segment([outer_point, inner_point], egui::Stroke::new(2.0, color)); + } + + // Draw outer circle + painter.circle_stroke( + center, + outer_radius - tooth_depth / 2.0, + egui::Stroke::new(2.0, color), + ); + + // Draw inner circle (hole) + painter.circle_stroke(center, inner_radius, egui::Stroke::new(1.5, color)); + } + } + } +} diff --git a/src/ui/channels.rs b/src/ui/channels.rs index 2fde154..b0e4a7a 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -398,24 +398,104 @@ impl UltraLogApp { .corner_radius(5) .inner_margin(10.0) .show(ui, |ui| { - ui.vertical(|ui| { - ui.horizontal(|ui| { - // Show computed channel indicator - if card.is_computed { + // Use horizontal layout with content on left, close button on right + ui.horizontal(|ui| { + // Main content column + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Show computed channel indicator + if card.is_computed { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::from_rgb(150, 200, 255)) + .strong(), + ) + .on_hover_text("Computed channel (formula-based)"); + } ui.label( - egui::RichText::new("ƒ") - .color(egui::Color32::from_rgb(150, 200, 255)) - .strong(), - ) - .on_hover_text("Computed channel (formula-based)"); + egui::RichText::new(&card.display_name) + .strong() + .color(card.color) + .size(font_14), + ); + }); + + // Show min with jump button + if let Some(min_str) = &card.min_str { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Min:") + .color(egui::Color32::GRAY) + .size(font_12), + ); + ui.label( + egui::RichText::new(min_str) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + if let (Some(record), Some(time)) = + (card.min_record, card.min_time) + { + let btn = ui + .small_button("⏵") + .on_hover_text("Jump to minimum"); + if btn.clicked() { + jump_to = Some((record, time)); + } + if btn.hovered() { + ui.ctx().set_cursor_icon( + egui::CursorIcon::PointingHand, + ); + } + } + }); } - ui.label( - egui::RichText::new(&card.display_name) - .strong() - .color(card.color) - .size(font_14), + + // Show max with jump button + if let Some(max_str) = &card.max_str { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Max:") + .color(egui::Color32::GRAY) + .size(font_12), + ); + ui.label( + egui::RichText::new(max_str) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + if let (Some(record), Some(time)) = + (card.max_record, card.max_time) + { + let btn = ui + .small_button("⏵") + .on_hover_text("Jump to maximum"); + if btn.clicked() { + jump_to = Some((record, time)); + } + if btn.hovered() { + ui.ctx().set_cursor_icon( + egui::CursorIcon::PointingHand, + ); + } + } + }); + } + }); + + // Close button in top right + ui.add_space(8.0); + ui.vertical(|ui| { + let close_btn = ui.add( + egui::Button::new( + egui::RichText::new("✕") + .size(font_12) + .color(egui::Color32::from_rgb(150, 150, 150)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .corner_radius(2.0), ); - let close_btn = ui.small_button("x"); if close_btn.clicked() { channel_to_remove = Some(i); } @@ -423,68 +503,6 @@ impl UltraLogApp { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } }); - - // Show min with jump button - if let Some(min_str) = &card.min_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Min:") - .color(egui::Color32::GRAY) - .size(font_12), - ); - ui.label( - egui::RichText::new(min_str) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), - ); - if let (Some(record), Some(time)) = - (card.min_record, card.min_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to minimum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); - } - - // Show max with jump button - if let Some(max_str) = &card.max_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Max:") - .color(egui::Color32::GRAY) - .size(font_12), - ); - ui.label( - egui::RichText::new(max_str) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), - ); - if let (Some(record), Some(time)) = - (card.max_record, card.max_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to maximum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); - } }); }); diff --git a/src/ui/channels_panel.rs b/src/ui/channels_panel.rs new file mode 100644 index 0000000..18b43b8 --- /dev/null +++ b/src/ui/channels_panel.rs @@ -0,0 +1,295 @@ +//! Channels panel - channel selection and selected channel cards. +//! +//! This panel provides channel selection functionality that works across all tool modes. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::normalize::sort_channels_by_priority; +use crate::state::MAX_CHANNELS; + +impl UltraLogApp { + /// Render the channels panel content (called from side_panel.rs) + pub fn render_channels_panel_content(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + let _font_16 = self.scaled_font(16.0); + + // Get active tab info + let tab_info = self.active_tab.and_then(|tab_idx| { + let tab = &self.tabs[tab_idx]; + if tab.file_index < self.files.len() { + Some(( + tab.file_index, + tab.channel_search.clone(), + tab.selected_channels.len(), + )) + } else { + None + } + }); + + if let Some((file_index, current_search, selected_count)) = tab_info { + let channel_count = self.files[file_index].log.channels.len(); + + // Computed Channels button + let primary_color = egui::Color32::from_rgb(113, 120, 78); + let computed_btn = egui::Frame::NONE + .fill(primary_color) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::WHITE) + .size(font_14), + ); + ui.label( + egui::RichText::new("Computed Channels") + .color(egui::Color32::WHITE) + .size(font_14), + ); + }); + }); + + if computed_btn + .response + .interact(egui::Sense::click()) + .on_hover_text("Create virtual channels from mathematical formulas") + .clicked() + { + self.show_computed_channels_manager = true; + } + + if computed_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + ui.add_space(8.0); + + // Search box + let mut search_text = current_search; + let mut search_changed = false; + egui::Frame::NONE + .fill(egui::Color32::from_rgb(50, 50, 50)) + .corner_radius(4) + .inner_margin(egui::vec2(8.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("🔍") + .size(font_14) + .color(egui::Color32::GRAY), + ); + let response = ui.add( + egui::TextEdit::singleline(&mut search_text) + .hint_text("Search channels...") + .desired_width(f32::INFINITY) + .frame(false), + ); + search_changed = response.changed(); + }); + }); + + if search_changed { + self.set_channel_search(search_text.clone()); + } + + ui.add_space(4.0); + + // Channel count + ui.label( + egui::RichText::new(format!( + "Selected: {} / {} | Total: {}", + selected_count, MAX_CHANNELS, channel_count + )) + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + + // Channel list + self.render_channel_list_compact(ui, file_index, &search_text); + } else { + // No file selected + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("📊") + .size(32.0) + .color(egui::Color32::from_rgb(100, 100, 100)), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("No file selected") + .size(font_14) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("Load a file to view channels") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)), + ); + }); + } + } + + /// Render the channel list in compact form + fn render_channel_list_compact(&mut self, ui: &mut egui::Ui, file_index: usize, search: &str) { + let font_14 = self.scaled_font(14.0); + let search_lower = search.to_lowercase(); + + let mut channel_to_add: Option<(usize, usize)> = None; + let mut channel_to_remove: Option = None; + + let file = &self.files[file_index]; + let sorted_channels = sort_channels_by_priority( + file.log.channels.len(), + |idx| file.log.channels[idx].name(), + self.field_normalization, + Some(&self.custom_normalizations), + ); + + let channel_names: Vec = (0..file.log.channels.len()) + .map(|idx| file.log.channels[idx].name()) + .collect(); + + let channels_with_data = &file.channels_with_data; + let (channels_with, channels_without): (Vec<_>, Vec<_>) = sorted_channels + .into_iter() + .partition(|(idx, _, _)| channels_with_data[*idx]); + + let selected_channels = self.get_selected_channels().to_vec(); + + let render_channel = |ui: &mut egui::Ui, + channel_index: usize, + display_name: &str, + is_empty: bool, + channel_to_add: &mut Option<(usize, usize)>, + channel_to_remove: &mut Option| { + let original_name = &channel_names[channel_index]; + + if !search_lower.is_empty() + && !original_name.to_lowercase().contains(&search_lower) + && !display_name.to_lowercase().contains(&search_lower) + { + return; + } + + let selected_idx = selected_channels + .iter() + .position(|c| c.file_index == file_index && c.channel_index == channel_index); + let is_selected = selected_idx.is_some(); + + let text_color = if is_empty { + egui::Color32::from_rgb(100, 100, 100) + } else if is_selected { + egui::Color32::WHITE + } else { + egui::Color32::LIGHT_GRAY + }; + + let bg_color = if is_selected { + egui::Color32::from_rgb(55, 60, 50) + } else { + egui::Color32::TRANSPARENT + }; + + let frame = egui::Frame::NONE + .fill(bg_color) + .corner_radius(3) + .inner_margin(egui::Margin::symmetric(6, 3)); + + let response = frame + .show(ui, |ui| { + ui.horizontal(|ui| { + let check = if is_selected { "☑" } else { "☐" }; + ui.label(egui::RichText::new(check).size(font_14).color(text_color)); + ui.label( + egui::RichText::new(display_name) + .size(font_14) + .color(text_color), + ); + }); + }) + .response + .interact(egui::Sense::click()); + + if response.clicked() { + if let Some(idx) = selected_idx { + *channel_to_remove = Some(idx); + } else { + *channel_to_add = Some((file_index, channel_index)); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }; + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Channels with data + if !channels_with.is_empty() { + egui::CollapsingHeader::new( + egui::RichText::new(format!("📊 With Data ({})", channels_with.len())) + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + for (channel_index, display_name, _) in &channels_with { + render_channel( + ui, + *channel_index, + display_name, + false, + &mut channel_to_add, + &mut channel_to_remove, + ); + } + }); + } + + ui.add_space(4.0); + + // Empty channels + if !channels_without.is_empty() { + egui::CollapsingHeader::new( + egui::RichText::new(format!("📭 Empty ({})", channels_without.len())) + .size(font_14) + .color(egui::Color32::GRAY), + ) + .default_open(false) + .show(ui, |ui| { + for (channel_index, display_name, _) in &channels_without { + render_channel( + ui, + *channel_index, + display_name, + true, + &mut channel_to_add, + &mut channel_to_remove, + ); + } + }); + } + }); + + if let Some(idx) = channel_to_remove { + self.remove_channel(idx); + } + + if let Some((file_idx, channel_idx)) = channel_to_add { + self.add_channel(file_idx, channel_idx); + } + } +} diff --git a/src/ui/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs index 30b411b..6f5084d 100644 --- a/src/ui/computed_channels_manager.rs +++ b/src/ui/computed_channels_manager.rs @@ -257,7 +257,7 @@ impl UltraLogApp { let _ = self.computed_library.save(); } - if let Some(template) = template_to_apply { + if let Some(ref template) = template_to_apply { self.apply_computed_channel_template(template); } } @@ -530,7 +530,7 @@ impl UltraLogApp { } /// Apply a computed channel template to the current file - fn apply_computed_channel_template(&mut self, template: ComputedChannelTemplate) { + pub fn apply_computed_channel_template(&mut self, template: &ComputedChannelTemplate) { let Some(tab_idx) = self.active_tab else { self.show_toast_warning("No active tab"); return; diff --git a/src/ui/files_panel.rs b/src/ui/files_panel.rs new file mode 100644 index 0000000..0eeb695 --- /dev/null +++ b/src/ui/files_panel.rs @@ -0,0 +1,282 @@ +//! Files panel - file management, loading, and file list. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::LoadingState; +use crate::ui::icons::draw_upload_icon; + +impl UltraLogApp { + /// Render the files panel content (called from side_panel.rs) + pub fn render_files_panel_content(&mut self, ui: &mut egui::Ui) { + // Show loading indicator + if let LoadingState::Loading(filename) = &self.loading_state { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(format!("Loading {}...", filename)); + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + } + + let is_loading = matches!(self.loading_state, LoadingState::Loading(_)); + + // File list (if any files loaded) + if !self.files.is_empty() { + self.render_file_list(ui); + + ui.add_space(10.0); + + // Add more files button + ui.add_enabled_ui(!is_loading, |ui| { + self.render_add_file_button(ui); + }); + } else if !is_loading { + // Nice drop zone when no files loaded + self.render_drop_zone_card(ui); + } + } + + /// Render the list of loaded files + fn render_file_list(&mut self, ui: &mut egui::Ui) { + let mut file_to_remove: Option = None; + let mut file_to_switch: Option = None; + + // Collect file info upfront to avoid borrow issues + let file_info: Vec<(String, bool, String, usize, usize)> = self + .files + .iter() + .enumerate() + .map(|(i, file)| { + ( + file.name.clone(), + self.selected_file == Some(i), + file.ecu_type.name().to_string(), + file.log.channels.len(), + file.log.data.len(), + ) + }) + .collect(); + + ui.label( + egui::RichText::new(format!("Loaded Files ({})", file_info.len())) + .size(self.scaled_font(13.0)) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + for (i, (file_name, is_selected, ecu_name, channel_count, data_count)) in + file_info.iter().enumerate() + { + // File card with selection highlight + let card_bg = if *is_selected { + egui::Color32::from_rgb(50, 55, 45) // Subtle olive tint for selected + } else { + egui::Color32::from_rgb(40, 40, 40) + }; + + let card_border = if *is_selected { + egui::Color32::from_rgb(113, 120, 78) // Olive green for selected + } else { + egui::Color32::from_rgb(55, 55, 55) + }; + + egui::Frame::NONE + .fill(card_bg) + .stroke(egui::Stroke::new(1.0, card_border)) + .corner_radius(6) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // File name (clickable) + let response = ui.add( + egui::Label::new( + egui::RichText::new(file_name) + .size(self.scaled_font(14.0)) + .color(if *is_selected { + egui::Color32::WHITE + } else { + egui::Color32::LIGHT_GRAY + }), + ) + .sense(egui::Sense::click()), + ); + + if response.clicked() { + file_to_switch = Some(i); + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Delete button + let close_btn = ui.add( + egui::Label::new( + egui::RichText::new("×") + .size(self.scaled_font(16.0)) + .color(egui::Color32::from_rgb(150, 150, 150)), + ) + .sense(egui::Sense::click()), + ); + + if close_btn.clicked() { + file_to_remove = Some(i); + } + + if close_btn.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + }); + + // ECU type and data info + ui.label( + egui::RichText::new(format!( + "{} • {} ch • {} pts", + ecu_name, channel_count, data_count + )) + .size(self.scaled_font(11.0)) + .color(egui::Color32::GRAY), + ); + }); + + ui.add_space(4.0); + } + + // Handle deferred file switching + if let Some(index) = file_to_switch { + self.switch_to_file_tab(index); + } + + if let Some(index) = file_to_remove { + self.remove_file(index); + } + } + + /// Render the "Add File" button + fn render_add_file_button(&mut self, ui: &mut egui::Ui) { + let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green + + let button_response = egui::Frame::NONE + .fill(primary_color) + .corner_radius(6) + .inner_margin(egui::vec2(16.0, 8.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("+") + .color(egui::Color32::WHITE) + .size(self.scaled_font(16.0)), + ); + ui.label( + egui::RichText::new("Add File") + .color(egui::Color32::WHITE) + .size(self.scaled_font(14.0)), + ); + }); + }); + + if button_response + .response + .interact(egui::Sense::click()) + .clicked() + { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + } + + if button_response.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + + /// Render the drop zone card for when no files are loaded + fn render_drop_zone_card(&mut self, ui: &mut egui::Ui) { + let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green + let card_bg = egui::Color32::from_rgb(45, 45, 45); + let text_gray = egui::Color32::from_rgb(150, 150, 150); + + ui.add_space(20.0); + + // Drop zone card + egui::Frame::NONE + .fill(card_bg) + .corner_radius(12) + .inner_margin(20.0) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + // Upload icon + let icon_size = 32.0; + let (icon_rect, _) = ui.allocate_exact_size( + egui::vec2(icon_size, icon_size), + egui::Sense::hover(), + ); + draw_upload_icon(ui, icon_rect.center(), icon_size, primary_color); + + ui.add_space(12.0); + + // Select file button + let button_response = egui::Frame::NONE + .fill(primary_color) + .corner_radius(6) + .inner_margin(egui::vec2(16.0, 8.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Select a file") + .color(egui::Color32::WHITE) + .size(self.scaled_font(14.0)), + ); + }); + + if button_response + .response + .interact(egui::Sense::click()) + .clicked() + { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + } + + if button_response.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + ui.add_space(12.0); + + ui.label( + egui::RichText::new("or") + .color(text_gray) + .size(self.scaled_font(12.0)), + ); + + ui.add_space(8.0); + + ui.label( + egui::RichText::new("Drop file here") + .color(egui::Color32::LIGHT_GRAY) + .size(self.scaled_font(13.0)), + ); + + ui.add_space(12.0); + + ui.label( + egui::RichText::new("CSV • LOG • TXT • MLG") + .color(text_gray) + .size(self.scaled_font(11.0)), + ); + }); + }); + } +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index ab5bda3..1bf10b7 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,14 +1,11 @@ -//! Menu bar UI components (File, View, Units, Help menus). +//! Menu bar UI components (File, View, Help menus). +//! +//! Simplified menu structure - settings moved to Settings panel. use eframe::egui; -use crate::analytics; use crate::app::UltraLogApp; -use crate::state::{ActiveTool, FontScale, LoadingState}; -use crate::units::{ - AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, - TemperatureUnit, VolumeUnit, -}; +use crate::state::{ActivePanel, ActiveTool, LoadingState}; impl UltraLogApp { /// Render the application menu bar @@ -39,7 +36,8 @@ impl UltraLogApp { // Open file option if ui - .add_enabled(!is_loading, egui::Button::new("📂 Open Log File...")) + .add_enabled(!is_loading, egui::Button::new("Open Log File...")) + .on_hover_text("⌘O") .clicked() { if let Some(path) = rfd::FileDialog::new() @@ -53,6 +51,21 @@ impl UltraLogApp { ui.separator(); + // Close current tab + let has_tabs = !self.tabs.is_empty(); + if ui + .add_enabled(has_tabs, egui::Button::new("Close Tab")) + .on_hover_text("⌘W") + .clicked() + { + if let Some(tab_idx) = self.active_tab { + self.close_tab(tab_idx); + } + ui.close(); + } + + ui.separator(); + // Export submenu - context-aware based on active tool let has_chart_data = !self.files.is_empty() && !self.get_selected_channels().is_empty(); @@ -68,20 +81,17 @@ impl UltraLogApp { let can_export = has_chart_data || has_histogram_data; ui.add_enabled_ui(can_export, |ui| { - ui.menu_button("📤 Export", |ui| { - // Increase font size for submenu items + ui.menu_button("Export", |ui| { ui.style_mut() .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if self.active_tool == ActiveTool::Histogram && has_histogram_data { - // Histogram export options if ui.button("Export Histogram as PDF...").clicked() { self.export_histogram_pdf(); ui.close(); } } else if has_chart_data { - // Chart export options if ui.button("Export as PNG...").clicked() { self.export_chart_png(); ui.close(); @@ -95,11 +105,10 @@ impl UltraLogApp { }); }); - // View menu + // View menu - tool modes and panels ui.menu_button("View", |ui| { - ui.set_min_width(180.0); + ui.set_min_width(200.0); - // Increase font size for dropdown items ui.style_mut() .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); @@ -107,392 +116,82 @@ impl UltraLogApp { .text_styles .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - // Cursor Tracking toggle + // Tool modes + ui.label( + egui::RichText::new("Tool Mode") + .size(font_14) + .color(egui::Color32::GRAY), + ); + if ui - .checkbox(&mut self.cursor_tracking, "🎯 Cursor Tracking") + .radio_value(&mut self.active_tool, ActiveTool::LogViewer, "Log Viewer") + .on_hover_text("⌘1") .clicked() { ui.close(); } - - // Color Blind Mode toggle - let old_color_blind_mode = self.color_blind_mode; if ui - .checkbox(&mut self.color_blind_mode, "👁 Color Blind Mode") + .radio_value( + &mut self.active_tool, + ActiveTool::ScatterPlot, + "Scatter Plots", + ) + .on_hover_text("⌘2") .clicked() { - if self.color_blind_mode != old_color_blind_mode { - analytics::track_colorblind_mode_toggled(self.color_blind_mode); - } ui.close(); } - - ui.separator(); - - // Field Normalization toggle if ui - .checkbox(&mut self.field_normalization, "📝 Field Normalization") - .on_hover_text("Standardize channel names across different ECU types") + .radio_value(&mut self.active_tool, ActiveTool::Histogram, "Histogram") + .on_hover_text("⌘3") .clicked() { ui.close(); } - // Edit mappings button - if ui.button(" Edit Mappings...").clicked() { - self.show_normalization_editor = true; - ui.close(); - } - ui.separator(); - // Auto-update preference + // Panel navigation + ui.label( + egui::RichText::new("Side Panel") + .size(font_14) + .color(egui::Color32::GRAY), + ); + if ui - .checkbox( - &mut self.auto_check_updates, - "🔄 Check for Updates on Startup", - ) - .on_hover_text("Automatically check for new versions when the app starts") + .radio_value(&mut self.active_panel, ActivePanel::Files, "Files") + .on_hover_text("⌘⇧F") .clicked() { ui.close(); } - - ui.separator(); - - // Font Size submenu - ui.menu_button("🔠 Font Size", |ui| { - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - - if ui - .radio_value(&mut self.font_scale, FontScale::Small, "Small (85%)") - .clicked() - { - ui.close(); - } - if ui - .radio_value(&mut self.font_scale, FontScale::Medium, "Medium (100%)") - .clicked() - { - ui.close(); - } - if ui - .radio_value(&mut self.font_scale, FontScale::Large, "Large (120%)") - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.font_scale, - FontScale::ExtraLarge, - "Extra Large (140%)", - ) - .clicked() - { - ui.close(); - } - }); - }); - - // Units menu - ui.menu_button("Units", |ui| { - ui.set_min_width(180.0); - - // Increase font size for dropdown items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - - // Temperature submenu - ui.menu_button("°C Temperature", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Celsius, - "Celsius (°C)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Fahrenheit, - "Fahrenheit (°F)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Kelvin, - "Kelvin (K)", - ) - .clicked() - { - ui.close(); - } - }); - - // Pressure submenu - ui.menu_button("💨 Pressure", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::KPa, - "Kilopascal (kPa)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::PSI, - "PSI", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::Bar, - "Bar", - ) - .clicked() - { - ui.close(); - } - }); - - // Speed submenu - ui.menu_button("🚗 Speed", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.speed, - SpeedUnit::KmH, - "Kilometers/hour (km/h)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.speed, - SpeedUnit::Mph, - "Miles/hour (mph)", - ) - .clicked() - { - ui.close(); - } - }); - - // Distance submenu - ui.menu_button("📏 Distance", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.distance, - DistanceUnit::Kilometers, - "Kilometers (km)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.distance, - DistanceUnit::Miles, - "Miles (mi)", - ) - .clicked() - { - ui.close(); - } - }); - - ui.separator(); - - // Fuel Economy submenu - ui.menu_button("⛽ Fuel Economy", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::LPer100Km, - "Liters/100km (L/100km)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::Mpg, - "Miles/gallon (mpg)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::KmPerL, - "Kilometers/liter (km/L)", - ) - .clicked() - { - ui.close(); - } - }); - - // Volume submenu - ui.menu_button("📊 Volume", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.volume, - VolumeUnit::Liters, - "Liters (L)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.volume, - VolumeUnit::Gallons, - "Gallons (gal)", - ) - .clicked() - { - ui.close(); - } - }); - - // Flow submenu - ui.menu_button("💧 Flow Rate", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.flow, - FlowUnit::CcPerMin, - "cc/min", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value(&mut self.unit_preferences.flow, FlowUnit::LbPerHr, "lb/hr") - .clicked() - { - ui.close(); - } - }); - - ui.separator(); - - // Acceleration submenu - ui.menu_button("📈 Acceleration", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.acceleration, - AccelerationUnit::MPerS2, - "m/s²", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.acceleration, - AccelerationUnit::G, - "g-force (g)", - ) - .clicked() - { - ui.close(); - } - }); - }); - - // Channels menu - ui.menu_button("Channels", |ui| { - ui.set_min_width(200.0); - - // Increase font size for dropdown items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - - if ui.button("ƒ(x) Computed Channels...").clicked() { - self.show_computed_channels_manager = true; + if ui + .radio_value(&mut self.active_panel, ActivePanel::Channels, "Channels") + .on_hover_text("⌘⇧C") + .clicked() + { ui.close(); } - - ui.separator(); - - if ui.button("📊 Analysis Tools...").clicked() { - self.show_analysis_panel = true; + if ui + .radio_value(&mut self.active_panel, ActivePanel::Tools, "Tools") + .on_hover_text("⌘⇧T") + .clicked() + { + ui.close(); + } + if ui + .radio_value(&mut self.active_panel, ActivePanel::Settings, "Settings") + .on_hover_text("⌘,") + .clicked() + { ui.close(); } }); + // Help menu ui.menu_button("Help", |ui| { ui.set_min_width(200.0); - // Increase font size for dropdown items ui.style_mut() .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); @@ -500,19 +199,19 @@ impl UltraLogApp { .text_styles .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - if ui.button("📖 Documentation").clicked() { + if ui.button("Documentation").clicked() { let _ = open::that("https://github.com/SomethingNew71/UltraLog/wiki"); ui.close(); } - if ui.button("🐛 Report Issue").clicked() { + if ui.button("Report Issue").clicked() { let _ = open::that("https://github.com/SomethingNew71/UltraLog/issues"); ui.close(); } ui.separator(); - if ui.button("💝 Support Development").clicked() { + if ui.button("Support Development").clicked() { let _ = open::that("https://github.com/sponsors/SomethingNew71"); ui.close(); } @@ -526,9 +225,9 @@ impl UltraLogApp { | crate::updater::UpdateState::Downloading ); let button_text = if is_checking { - "🔄 Checking for Updates..." + "Checking for Updates..." } else { - "🔄 Check for Updates" + "Check for Updates" }; if ui diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a55d2f4..84ba92a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,21 +1,43 @@ //! UI rendering modules for the UltraLog application. //! //! This module organizes the various UI components into logical submodules: -//! - `sidebar` - Files panel and view options -//! - `channels` - Channel selection and display +//! +//! ## New Activity Bar Architecture +//! - `activity_bar` - VS Code-style vertical icon strip for panel navigation +//! - `side_panel` - Container that routes to the appropriate panel +//! - `files_panel` - File management, loading, and file list +//! - `channels_panel` - Channel selection (works in all modes) +//! - `tools_panel` - Analysis tools, computed channels, export +//! - `settings_panel` - Consolidated settings (display, units, normalization, updates) +//! +//! ## Core UI Components +//! - `sidebar` - Legacy files panel (being replaced by files_panel) +//! - `channels` - Legacy channel selection (being replaced by channels_panel) //! - `chart` - Main chart rendering and legends //! - `timeline` - Timeline scrubber and playback controls -//! - `menu` - Menu bar (File, Units, Help) +//! - `menu` - Menu bar (File, Edit, View, Help) //! - `toast` - Toast notification system //! - `icons` - Custom icon drawing utilities //! - `export` - Chart export functionality (PNG, PDF) //! - `normalization_editor` - Field normalization customization window -//! - `tool_switcher` - Pill-style tab navigation between tools +//! - `tool_switcher` - Tool mode selection (Log Viewer, Scatter Plot, Histogram) //! - `scatter_plot` - Scatter plot visualization view +//! - `histogram` - Histogram visualization view //! - `tab_bar` - Chrome-style tabs for managing multiple log files //! - `update_dialog` - Auto-update dialog window //! - `analysis_panel` - Signal analysis tools window +//! - `computed_channels_manager` - Computed channels library manager +//! - `formula_editor` - Formula creation and editing + +// New activity bar architecture +pub mod activity_bar; +pub mod channels_panel; +pub mod files_panel; +pub mod settings_panel; +pub mod side_panel; +pub mod tools_panel; +// Core UI components pub mod analysis_panel; pub mod channels; pub mod chart; diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs new file mode 100644 index 0000000..8d8fbc5 --- /dev/null +++ b/src/ui/settings_panel.rs @@ -0,0 +1,460 @@ +//! Settings panel - consolidated settings for display, units, normalization, and updates. +//! +//! This panel provides a single location for all user preferences. + +use eframe::egui; + +use crate::analytics; +use crate::app::UltraLogApp; +use crate::state::FontScale; +use crate::units::{ + AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, + TemperatureUnit, VolumeUnit, +}; +use crate::updater::UpdateState; + +impl UltraLogApp { + /// Render the settings panel content (called from side_panel.rs) + pub fn render_settings_panel_content(&mut self, ui: &mut egui::Ui) { + // Display settings section + self.render_display_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Field normalization settings + self.render_normalization_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Unit preferences + self.render_unit_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Update settings + self.render_update_settings(ui); + } + + /// Render display settings section + fn render_display_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("🖥 Display").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + // Colorblind mode + let old_color_blind_mode = self.color_blind_mode; + ui.checkbox( + &mut self.color_blind_mode, + egui::RichText::new("Color Blind Mode").size(font_14), + ); + if self.color_blind_mode != old_color_blind_mode { + analytics::track_colorblind_mode_toggled(self.color_blind_mode); + } + ui.label( + egui::RichText::new("Use accessible color palette (Wong's palette)") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Font size + ui.label(egui::RichText::new("Font Size:").size(font_14)); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.font_scale, FontScale::Small, "S"); + ui.selectable_value(&mut self.font_scale, FontScale::Medium, "M"); + ui.selectable_value(&mut self.font_scale, FontScale::Large, "L"); + ui.selectable_value(&mut self.font_scale, FontScale::ExtraLarge, "XL"); + }); + + ui.add_space(8.0); + + // Cursor tracking + ui.checkbox( + &mut self.cursor_tracking, + egui::RichText::new("Cursor Tracking").size(font_14), + ); + ui.label( + egui::RichText::new("Keep cursor centered while scrubbing") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + if self.cursor_tracking { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Window:").size(font_12)); + ui.add( + egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) + .suffix("s") + .logarithmic(true) + .text(""), + ); + }); + } + }); + } + + /// Render field normalization settings + fn render_normalization_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📝 Field Names").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.checkbox( + &mut self.field_normalization, + egui::RichText::new("Field Normalization").size(font_14), + ); + ui.label( + egui::RichText::new("Standardize channel names across ECU types") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Custom mappings count + let custom_count = self.custom_normalizations.len(); + if custom_count > 0 { + ui.label( + egui::RichText::new(format!("{} custom mappings", custom_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + } + + // Edit mappings button + let btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Edit Custom Mappings") + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + }); + + if btn.response.interact(egui::Sense::click()).clicked() { + self.show_normalization_editor = true; + } + + if btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + } + + /// Render unit preferences + fn render_unit_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📐 Units").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Select preferred units for display") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + // Create a grid for unit selections + egui::Grid::new("unit_settings_grid") + .num_columns(2) + .spacing([8.0, 6.0]) + .show(ui, |ui| { + // Temperature + ui.label(egui::RichText::new("Temperature:").size(font_12)); + egui::ComboBox::from_id_salt("temp_unit") + .selected_text(self.unit_preferences.temperature.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Celsius, + "°C", + ); + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Fahrenheit, + "°F", + ); + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Kelvin, + "K", + ); + }); + ui.end_row(); + + // Pressure + ui.label(egui::RichText::new("Pressure:").size(font_12)); + egui::ComboBox::from_id_salt("pressure_unit") + .selected_text(self.unit_preferences.pressure.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::KPa, + "kPa", + ); + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::PSI, + "psi", + ); + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::Bar, + "bar", + ); + }); + ui.end_row(); + + // Speed + ui.label(egui::RichText::new("Speed:").size(font_12)); + egui::ComboBox::from_id_salt("speed_unit") + .selected_text(self.unit_preferences.speed.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.speed, + SpeedUnit::KmH, + "km/h", + ); + ui.selectable_value( + &mut self.unit_preferences.speed, + SpeedUnit::Mph, + "mph", + ); + }); + ui.end_row(); + + // Distance + ui.label(egui::RichText::new("Distance:").size(font_12)); + egui::ComboBox::from_id_salt("distance_unit") + .selected_text(self.unit_preferences.distance.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.distance, + DistanceUnit::Kilometers, + "km", + ); + ui.selectable_value( + &mut self.unit_preferences.distance, + DistanceUnit::Miles, + "mi", + ); + }); + ui.end_row(); + + // Fuel Economy + ui.label(egui::RichText::new("Fuel Economy:").size(font_12)); + egui::ComboBox::from_id_salt("fuel_unit") + .selected_text(self.unit_preferences.fuel_economy.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::LPer100Km, + "L/100km", + ); + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::Mpg, + "mpg", + ); + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::KmPerL, + "km/L", + ); + }); + ui.end_row(); + + // Volume + ui.label(egui::RichText::new("Volume:").size(font_12)); + egui::ComboBox::from_id_salt("volume_unit") + .selected_text(self.unit_preferences.volume.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.volume, + VolumeUnit::Liters, + "L", + ); + ui.selectable_value( + &mut self.unit_preferences.volume, + VolumeUnit::Gallons, + "gal", + ); + }); + ui.end_row(); + + // Flow Rate + ui.label(egui::RichText::new("Flow Rate:").size(font_12)); + egui::ComboBox::from_id_salt("flow_unit") + .selected_text(self.unit_preferences.flow.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.flow, + FlowUnit::CcPerMin, + "cc/min", + ); + ui.selectable_value( + &mut self.unit_preferences.flow, + FlowUnit::LbPerHr, + "lb/hr", + ); + }); + ui.end_row(); + + // Acceleration + ui.label(egui::RichText::new("Acceleration:").size(font_12)); + egui::ComboBox::from_id_salt("accel_unit") + .selected_text(self.unit_preferences.acceleration.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.acceleration, + AccelerationUnit::MPerS2, + "m/s²", + ); + ui.selectable_value( + &mut self.unit_preferences.acceleration, + AccelerationUnit::G, + "g", + ); + }); + ui.end_row(); + }); + }); + } + + /// Render update settings + fn render_update_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("🔄 Updates").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + // Auto-check preference + ui.checkbox( + &mut self.auto_check_updates, + egui::RichText::new("Check on startup").size(font_14), + ); + ui.label( + egui::RichText::new("Automatically check for new versions") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Check now button + let is_checking = matches!(self.update_state, UpdateState::Checking); + ui.add_enabled_ui(!is_checking, |ui| { + let btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + let text = if is_checking { + "Checking..." + } else { + "Check for Updates" + }; + ui.label( + egui::RichText::new(text) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + }); + + if !is_checking && btn.response.interact(egui::Sense::click()).clicked() { + self.start_update_check(); + } + + if btn.response.hovered() && !is_checking { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + ui.add_space(8.0); + + // Version info + let version = env!("CARGO_PKG_VERSION"); + ui.label( + egui::RichText::new(format!("Current version: {}", version)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + + // Show update status + match &self.update_state { + UpdateState::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label( + egui::RichText::new("Checking...") + .size(font_12) + .color(egui::Color32::GRAY), + ); + }); + } + UpdateState::UpdateAvailable(info) => { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!( + "✓ Update available: v{}", + info.new_version + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + + let view_btn = ui.add( + egui::Label::new( + egui::RichText::new("View Details →") + .size(font_12) + .color(egui::Color32::from_rgb(150, 180, 220)), + ) + .sense(egui::Sense::click()), + ); + + if view_btn.clicked() { + self.show_update_dialog = true; + } + + if view_btn.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + UpdateState::Error(msg) => { + ui.label( + egui::RichText::new(format!("⚠ {}", msg)) + .size(font_12) + .color(egui::Color32::from_rgb(200, 150, 100)), + ); + } + _ => {} + } + }); + } +} diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs new file mode 100644 index 0000000..671e60a --- /dev/null +++ b/src/ui/side_panel.rs @@ -0,0 +1,35 @@ +//! Side panel container - routes to the appropriate panel based on activity bar selection. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActivePanel; + +/// Default width of the side panel in pixels +pub const SIDE_PANEL_WIDTH: f32 = 280.0; + +/// Minimum width of the side panel +pub const SIDE_PANEL_MIN_WIDTH: f32 = 200.0; + +impl UltraLogApp { + /// Render the side panel content based on the active panel selection + pub fn render_side_panel(&mut self, ui: &mut egui::Ui) { + // Panel header with title + ui.horizontal(|ui| { + ui.heading(self.active_panel.name()); + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Route to the appropriate panel content + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| match self.active_panel { + ActivePanel::Files => self.render_files_panel_content(ui), + ActivePanel::Channels => self.render_channels_panel_content(ui), + ActivePanel::Tools => self.render_tools_panel_content(ui), + ActivePanel::Settings => self.render_settings_panel_content(ui), + }); + } +} diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs new file mode 100644 index 0000000..f01c352 --- /dev/null +++ b/src/ui/tools_panel.rs @@ -0,0 +1,356 @@ +//! Tools panel - analysis tools, computed channels library, and export options. +//! +//! Provides quick access to analysis and export functionality inline in the side panel. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActiveTool; + +impl UltraLogApp { + /// Render the tools panel content (called from side_panel.rs) + pub fn render_tools_panel_content(&mut self, ui: &mut egui::Ui) { + let _font_12 = self.scaled_font(12.0); + let _font_14 = self.scaled_font(14.0); + + // Analysis Tools Section + self.render_tools_analysis_section(ui); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Computed Channels Section + self.render_tools_computed_section(ui); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Export Section + self.render_tools_export_section(ui); + } + + /// Render the analysis tools section + fn render_tools_analysis_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new( + egui::RichText::new("📈 Analysis Tools") + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Run signal processing and statistical analysis on log data.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + + if has_file { + // Show available analyzers count + let analyzer_count = self.analyzer_registry.all().len(); + ui.label( + egui::RichText::new(format!("{} analyzers available", analyzer_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + // Open full analysis panel button + let primary_color = egui::Color32::from_rgb(113, 120, 78); + let btn = egui::Frame::NONE + .fill(primary_color) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Open Analysis Panel") + .color(egui::Color32::WHITE) + .size(font_14), + ); + }); + + if btn.response.interact(egui::Sense::click()).clicked() { + self.show_analysis_panel = true; + } + + if btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Show results count if any + if let Some(file_idx) = self.selected_file { + if let Some(results) = self.analysis_results.get(&file_idx) { + if !results.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "✓ {} analysis results for current file", + results.len() + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + } + } + } + } else { + ui.label( + egui::RichText::new("Load a file to access analysis tools") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } + }); + } + + /// Render the computed channels section + fn render_tools_computed_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new( + egui::RichText::new("ƒ Computed Channels") + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Create virtual channels from mathematical formulas.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + // Library count + let template_count = self.computed_library.templates.len(); + ui.label( + egui::RichText::new(format!("{} templates in library", template_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + // Buttons row + ui.horizontal(|ui| { + // New Channel button + let accent_color = egui::Color32::from_rgb(113, 120, 78); + let new_btn = egui::Frame::NONE + .fill(accent_color) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 5.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("+ New") + .color(egui::Color32::WHITE) + .size(font_12), + ); + }); + + if new_btn.response.interact(egui::Sense::click()).clicked() { + self.formula_editor_state.open_new(); + } + + if new_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Manage Library button + let manage_btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 5.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Manage Library") + .color(egui::Color32::LIGHT_GRAY) + .size(font_12), + ); + }); + + if manage_btn.response.interact(egui::Sense::click()).clicked() { + self.show_computed_channels_manager = true; + } + + if manage_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + // Show applied channels for current file + if let Some(file_idx) = self.selected_file { + if let Some(channels) = self.file_computed_channels.get(&file_idx) { + if !channels.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "✓ {} computed channels on current file", + channels.len() + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + } + } + } + + // Quick apply section + if !self.computed_library.templates.is_empty() && self.selected_file.is_some() { + ui.add_space(8.0); + ui.label( + egui::RichText::new("Quick Apply:") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + // Show first few templates as quick apply buttons + let templates: Vec<_> = self + .computed_library + .templates + .iter() + .take(5) + .map(|t| (t.id.clone(), t.name.clone())) + .collect(); + + for (id, name) in templates { + let response = ui.add( + egui::Label::new( + egui::RichText::new(format!(" • {}", name)) + .size(font_12) + .color(egui::Color32::from_rgb(150, 180, 220)), + ) + .sense(egui::Sense::click()), + ); + + if response.clicked() { + // Find and apply the template + if let Some(template) = + self.computed_library.templates.iter().find(|t| t.id == id) + { + let template_clone = template.clone(); + self.apply_computed_channel_template(&template_clone); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + } + }); + } + + /// Render the export section + fn render_tools_export_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📤 Export").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Export visualizations as images or documents.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + let has_data = self.selected_file.is_some() + && !self.files.is_empty() + && !self.get_selected_channels().is_empty(); + + let can_export_chart = has_data && self.active_tool == ActiveTool::LogViewer; + let can_export_histogram = self.selected_file.is_some() + && !self.files.is_empty() + && self.active_tool == ActiveTool::Histogram; + + ui.horizontal(|ui| { + // PNG Export + ui.add_enabled_ui(can_export_chart, |ui| { + let btn = egui::Frame::NONE + .fill(if can_export_chart { + egui::Color32::from_rgb(71, 108, 155) + } else { + egui::Color32::from_rgb(50, 50, 50) + }) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("PNG") + .color(if can_export_chart { + egui::Color32::WHITE + } else { + egui::Color32::GRAY + }) + .size(font_14), + ); + }); + + if can_export_chart && btn.response.interact(egui::Sense::click()).clicked() + { + self.export_chart_png(); + } + + if btn.response.hovered() && can_export_chart { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + // PDF Export + let can_export_pdf = can_export_chart || can_export_histogram; + ui.add_enabled_ui(can_export_pdf, |ui| { + let btn = egui::Frame::NONE + .fill(if can_export_pdf { + egui::Color32::from_rgb(155, 71, 71) + } else { + egui::Color32::from_rgb(50, 50, 50) + }) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("PDF") + .color(if can_export_pdf { + egui::Color32::WHITE + } else { + egui::Color32::GRAY + }) + .size(font_14), + ); + }); + + if can_export_pdf && btn.response.interact(egui::Sense::click()).clicked() { + if can_export_chart { + self.export_chart_pdf(); + } else if can_export_histogram { + self.export_histogram_pdf(); + } + } + + if btn.response.hovered() && can_export_pdf { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + }); + + if !has_data { + ui.add_space(4.0); + ui.label( + egui::RichText::new("Select channels to enable export") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } + }); + } +} From d7ce18c77a8a0cc3267df6a0456fb2839b942a54 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 11:47:13 -0500 Subject: [PATCH 05/13] Adds example logs from emerald ecus Signed-off-by: Cole Gentry --- .../EM Log MG ZS Turbo idle and rev.lg1 | Bin 0 -> 60360 bytes .../EM Log MG ZS Turbo idle and rev.lg2 | 22 ++++++++++++++++++ ...S Turbo short drive back diff channels.lg1 | Bin 0 -> 133680 bytes ...S Turbo short drive back diff channels.lg2 | 22 ++++++++++++++++++ .../EM Log MG ZS Turbo short drive.lg1 | Bin 0 -> 227208 bytes .../EM Log MG ZS Turbo short drive.lg2 | 22 ++++++++++++++++++ 6 files changed, 66 insertions(+) create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1 create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg2 create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1 create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg2 create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1 create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg2 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1 b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1 new file mode 100644 index 0000000000000000000000000000000000000000..487854926d2325dbe5ebcf40d23f9209b4d8f894 GIT binary patch literal 60360 zcmb8&b$Hau`!?{VnpB{;Q?yXr-QC>}UhLrR?pi36(w0Ig6k6I+ptw5+cZY+!yDsp~ z>}KY<_xBxMIRAdG`+Aa%OeT{_wtKb(AI}YcSgDOee}|k7>z#B*2ZuV27XADbwd?nU z_hb3hGC!{8LZ}ntl$_Nm$LeIs8*DnyM@!%L3_VJEzJ`k+Pu}ND2zb2AS6>tgzDD|k zmSIXyzX_ez^G8IundjRf(kY|4{zbnF=j$IM{rs*aP=A&5xXDZH+}EtLB_=}Y&s~-& zz181p|LhkD`B=H!(5L88(vuBbVdn97X~y~@QBX(xUd_l9ES%?8ncGt{cC}audGeo^ zSAnmy9uIuKUs@Wi^kE%hOy~7C%KY8^=o_SGuNDh+)|##v2c6@fj##c{EI$4_=lgN9 ztaB!JJk%#2pK!ikVrBhKg|tNw3lLu%Zq--_!pC9&a8en$cvKaK7KyNq<-Jh?%$do_mf$9dUeW zhR670kS8B@Pwi=PI^>~mh4VUe9VxTn5!4aKxkXPl^0Cs_ zmw5udL>~VS7YXOTOI&v}V|V+fcI#uierZN~pJz}f#_7HFx~3V!y1%f?+uWj)^YxE4 z^Ao0p70gwM9cLF_xPst@+XAz?J2G=nvrsW1(r+RJmn9lBiy3z8z$VgU&Qg>xzta{ zQ$Nb_i*TN&enpZ@+9$eCAs;zjIRCwo@^@vZ>}ck>i}Oe`rbekfS63%xzQ;pnSgzv9cU>_>}UrldRM$scC$ zg!Ne|kLM=ig!A>G^Zi{#FQ}t(+x8UgzygOPkY6V2drVJi=VHGPdnTOMq5YU9-W&3( z#M^5p+s5ze>6W?3h5o77Nehv^o5Px>_A z8>9!{5zf~$QTp);X<@nKmpi9Zdey_|qP?={_ok+Y>I8 z+n-(c-wEg2K^#Atv3f&h$kTaLd$12Imwey1ERYxNk7j&ql@;>jCw;Oh^L=j%=j*dl zp4V$T<$!#IJWi(O&IvB^7JbK*KXE$m+Sc%eI^-8l<^mVTp=JyY$Za}bPx9Mug!AKp z+KmZ`evlXKq-K06ofn*z8~Rl^ud`JC-ZDG#!TQkeU70z*(wjIHg!yTHi|t_1>(40)%N6(I7ClF^Vo+zZ?00p& zSR6b-_Ul}OOF$j+0bkJL%yAOUNWQ%!~FuT*+l6;=H zP=BASb9QGdsIyDDYx>qoUv&+Augs5)Yy3g!ZmClbJaX-@G9(uI&<2Blu`Th>R ztRF7&z1`YF9rF7D9qe3;6Hac}(XNj0oZg+3K5vller&mPpIEq9XQ)qp?map0mx}tU zclgUZ^@}We?a^JpML)`-Z@Q)S&Ih}}deZh>l(oC8V*B)cj4tl)EPB$_J)n;5 zIOq4#jq>z_I^uZKjGc3Y^X)H=Pt9oCpqDcLJxOn+4+-uA%cb>6)w1tzoNs^1PuMQ< z-040y>!^OPT-pwyjsu|ndUN||MwtWXTclr~G!W|0cBt`4?bCV;hCHojZoeV0+_g?m z&GG7=uY~jU-ypqM{h?4NQTm<2!=cV9xt{eGqSLs+i{>Ms&T5%IlyRiezm8D5>(WuM zTyZ>W#sFsE$kY8rUiWcOpN{|I$I!RS^QdHj@lc13+siM7 z^W~DK>pv0duaVo~b%{w(hdgHxx@b={<5087%6$17Q@|;IGkoeFIPI^o?WRFqjOS>^ z)4js^?;`J9YlfNUF6vlxCu1hmAzyJ+IM35{Drx>%kf(NRXRh4=eFyVW^{ZO2~Owl@dnGm>A3BkbcN}ByNUMAqI;fE`{b4@ zq0VyI4&+R`O6h^4&{xWQ;d0TCr}3YGyJEoUc}ktht4+79r)UqKR*QxFYIz>Dh(V|4 zJF{HkAWy!?FCKiAY$r=SReRF)YalQ7i$$-NBmtb-fj4j0g46G9+hQHKSgvM#PrY90 zOD_wz-wvrdY=FG&ew?3Q6ZWe;+oX+({Qjz&lwRieW^mhmG0*qQy+!HQXQA89 zN1pe7BHVVK+U|EO`lGd5p^oi&4bSH;yxnx3x6QlY?b6XVYA@D&2jmya`=*Pjc7jJr zufJp$EZ6qjlP~vWyWQX$Wjhv-d5@j*@hhJHIzc#J&z;us5bkB2_Ca1;uPu7=W5Ri! ze8bHBkf-|^pO@&O-(}I8);$1u^5f?Yg41=`q0k|4(ZAA+D<6gP^&ua=D*&8Xv0pVntw*Aq&AdPF#1F6C#< zI0<>G@09E`IE|N<>Z|q@XVreCz!|d+U#=LJw&?3Vpf8r~$(y0)ARjKr4+Be{hxHNH zBX-^YA)MDIU;5VtsIyGgsZ;3^)S+>|%`1f4wvTwO+Oo@K$ZxP7=jGV>RUq#bs4uS9 zn$a)*D)1eo0+tKsdGdn(cOftCn=~W(={=}V=S9u9 z`$~V@>jBiE{&2I?=yaSPZ}AA~P~Ksq+Iy#Y40&uJ&3 zj1kVakH}l}!quKbosIImIOqHV>d^j8G4&-l^`oBFdj)ki$?LvPtZ;sulYi>}8uH>i z)r?Ya-+hi*m_>o zkAC_28|TMWVGn-3hd5o(i??(5Yd z9?AS=S0~7i@hHUH$ML2f;Oh)t$Kw)P?mO+K{&fbruSWv&OUxJ7ae@3>_j$~>F~4!# z6@0LJ6!UQHt-fKt8+Z-({mcWHKYoRt*ZsEXdp7Hk|7h^vJD%koO1Xna7`>Sf&|Hnk z&*WJrw?d`9i%6JFdc+kL#1n%zLuqZGz6v7e_}Q!>2%U z@Xh*G&58BLe2iIv!o67jvObmhWajyrq%iZ`^B8rQuVWr{HKpmt*mA!bUmYAB+}L?k zEi9GkTbNgN&!#y#d|~I^h)=@fn74PYqiGILnK#;<+RQIuKFB?c`AO#UE2jO8k6`(7 z?7VPr=xeo`!gTfS?zZZp-=QY!SVqt z|J5s(xjtQ3{g5QQ-Mj4kSTER1cy;DJNk?nWtQ~u-FFqxl|K8h4+cRIn{7l~5W}S}? zz7E5^`SI4Ad94k8=6a51o-$bvc3wAhx~e}Ol*e>_e)%Nh?Zy|!t9rA`=;@O2_F*XV z_lxpE{)M+~9N?WIe zU!Ih=Gc}m!%w0(7ZKj}qPjZdr{j@5^x9Wu_QQc6_F1e$KN9_=F_3atjhkSjj`?36@(j~xCO24|jB=~SoUf+)$ z|J5>=0Qo!S2KG2H8xGxPRdF-CZ0md|I+KVbP-kMd@oU$5IUcVYATe2izaE0`Y0yn(0g z=;|=T>7IU|X+_h6m|yU;ok#a{|CE)$mwWZlTpemS-_t_|pci511wUWz>7(0LfqX7+ zUsnIJ^ILuWHsO4^FWK>C^Ts2qn)%VpHf@HCm+bpyX~w_&;8X+FU@>$ z3VwbycmAYb%vr&&aA+|SOtgHE6HG|@H9Jnx?jOnIC64CXKU z)KdD~0<}$_#PYLKK4tCs0>?c@!B@ih@!6RW<9Irl<>kCI!!L z)Akr+g6f&h&(mxvct6u$%k19lulkUGl)N+ZX3S@770&bXlJo0zN#-5LHGn!TlYcfn z(xH*n`Fk|J4w1=h=gSDA%0A(|j=%JT9F5I9cVFoZ9-u$=7IhByZVLID-Xq!h;>ph6 z?)jU6>)!nQ<$i9S+TC8FUrt)f%nxeZ9O?uoWtV+xoh&6c72%@ihexF zQq%LbX$AQyNqGN<=TD|@4c<`Zt1WK>e$b1zW9hY`#)zNjqr6_4o__CNkgw^**OTYp zOl=FE$tw?YKjuaB_TZDvcE-;*e+0d@*`D|r74vm~d}`^po}=@2h93`p#+D5oAy3|N za3}C~)_e`NKA($q2G120m+U&>f~N$qEf_JKO@-1vTPrj;`Kzehjm#;=3iACKw>`LS*>?6~sRyp0*<`h&M~ z*KNSZ|(Y zB-B~q!tcv?e(y;1K$oq|pQlb0?t73xg4jtBDm`hC;DTUo~^ zz1etplZn&qobN~OZ>!7zZ)|-|1o!^ zw#)P5fsb=eNq@Kb>FTn^2g_*;KQ8SvwUl|+z;6ofRB})XM)%#SQ0WzmMS(AJ;>Rst?ydLe^QDj9v=Z{Onm@>s|RJMr_8*WdMb9C(2B{N?`ku-cE!iHH0gnfFMV06yP|kB{*> zai`H2Sm*z6pA@_n@`2K~zO#3JAIN{PhR^Q`ZAf{H*pW-W2JYhx>xCr&ijl zue@}X<4|Y0%wKan0Uj;A$;^}BE2Zyxq4u|RPC=f2SNjC?a9QVP-_wvMANui(!Zjn! zma|IVI_w_8Vb<&@C08aU-MIS1C$2{~UvVPmfk04L}&qnkxnXg#x3FK+Hw=HTP zzw0UFZSUpr^?#n_8Mtl!0QXRr7vQ2ko6p}_?JNCWD)Tj-qKo>PF|F5Y$lK;I^5wog zW$(QI&wat%H;}i@%i`X%#oPbn@t5C0-u9fA*Qpos9^Cd_3+`LLslC5-=5cW!;QSff_I&{E$qu1YeoXE!kQej8Ec))%U%_eqTX6qx;Picf5``^F?-hVf z^Gh3!_yPG}xtNz?(I;QB&-3$+&(F-U=qJ>n_oOWC9KO(VSn~3>H1LJyajqFLRUDOG z+sO%B%x7cscn_e9^HeiB)pdb9wF8cxuHd2a{Oa2eJy3d*LT->JZ@69upC|LVh8s$6 zdl6myUW;Bd+#T}2_raMwAs;N~p{2TszCil(C@;v9XLU>h9wPIn9wr4}BE57SZ>3*z zPX;dL<5~0?eeK=$-V>Y0+av|#!_D`h*nRzG^o7!Y4owMp@`#eDz~{((nmwuiz!%8; z)^TYdA0qw54Rl)1gDcWPKHQ0aH;JFu$!li-U;IlK+hI&R<^$z+PBbz?9ojE>0yBY& zS19?Ofb1%p02xJXZF9EO-9I z6J$R*=5P+E6EE)v|IMBge2w%?Pu1Q$&etw4?oWgB%ikFnlg^WW7kZ&C12={4S}eN@9@kPngh+{H?O)BWIrMQShBs-#_B z9ETo0rIcQ8S!wVPd3xu%)fTX<hyXRclRYOIz@8IrUeyHrDVtYg^Q`Vo!4DV}nESK*{$qdQ%dOALc)m=N z=1`}k=|0A_j6;x>h^WEzAqJ zb!Nxyc=me(Rto3y!E~!{W8SrQdo!Pg`BPV(cXTXeta{PmH(r?KTf3EE=gUdQR7TB& zj%J?wT{k|TvxrkFBQaSg)A>Blf_fNhA3~TnJ%>JBZ^rx_^W&}k&HOjE{)OG!vv$Uh z&AaH8zKiMCnGbV+%I2qW_q;BAC-YbCI@=DO%!gO#YUYRwdzg8Co<8v4&%qZk504ej&!bx&{5g1Q=C$Yc zH1ph-d+_6&KPMXd9KDD2d_2P3F{BUVf8~{}=U3eS!4`&ZFZxX)xhfaTkE8UXnS!*-q)FurFU2tMB6^B?@R z0!EK1=+(^gtAJtD7zFu((kGl8Z1uk!_@_BUDfFY*y^J}5b=S}hF$om@%HGMYo zQM!2UTp&^SCgvsdYiz&se7~Z@&Ah!|3l`3=YhT@#vK`6iBPD$=JT>#1?0Gbw@5j#f z?IX-O>8(DLxi_14lril{(=#xC$DT(!v3YAg#u3XX(=#$Zs1Ibvc`7!quUN;?rl(+D z+qmK2#O5=z`F>f(nEsuu{{rJ2^HI#>9|?cVJc;{P<};YT=rPXBZ)W~4n@{V=>b%th z{KlKUn)wrUzG&<^@>c&cTlg~OU)gb@vH72G^|1OA%zUslAH@8I<6AxX#fhemWXJOh z>paY%>^hRTaFXeRn7_2nf2hMe^B3X#e0gkjALdP4Og8iOzGs_oe!e`k<{eqyq3l#M z&yR-~s~=&<;bm6e&lx=fe3QZF5shcUS=^}NmJn*M}&5B;#|B}dFt`idLEd3&qrL988qz~(JfTM}sIt1?f) zt~<`GpODH}>oDK+=FF?I`N=M9o^UFo(LUiFm~YbyF|W+p=fEmKX1*hHZ#Ey;#UX?9 zO}&S)(DbIvce44wE^I#PO?^o>;rzPL)8jTf{sWi~$scUy`Sr+&&C_$T`sRd1rtfCI z*U!tB{oV|$J#W!3#Pmq!e|wc-$LB%TZWkyKYC3NRW_yiiK8m^j0^xkV@eVIOZ;H3` z*V8XH^VW98i??HS*!C3>odGh&h+=p~UPwC0$Yw`B2b))5w53tT_ zYQ~Q9fHml?q!;MB0`ggRw?+;%>mOPXN%ZRWM~{>%rk{J=B9UotPOe`d#XI6vQ8t~2xR zm_KzZ#QYt*j`(as|KawY^>gyG>&3@W>&?8z&Wm)czr^pee2hiq5>4mt1+-?@5!?0U z%B~HjCuiz~U#30p3| zzI^xFV){1b&)iS2>rO+b7y6Q?!uffc&x2ogiZC}CY&G+9nAd0b7yP>OLXSsjAtdFuE=&lbGhbbcNU_Snge^X}|-?QgNebbkJh@Zjxse&(Mxpttql?X5TSQ5|+e zzL?b?u=zskcB1cd=l%0IcE4lq0q(qi$bH(RJy55ewO6=Dv-9_B5c&ajeDZc9y%E;@Ff4b4&f7`uWmErSI)A^TpMHk5tK2s%Ibu5RpXb+i zvg4WG-!*N2)bwQRIDG7u$UKhuo_)f7m|tT3D`$56=P)`>JZ9!|F@NL6@4M@;_aGvj zPMGe;JV5tlp32GF7=G+`o`>br8a>$cr8mp3$#v4K!@ZM{jd=+329JgF`W7Pz^MlL_ zpFeHp4dzYQdrwa6{{5!jt6LnV<0F*C{u4J(?9Rd?xeftew{!_OSbiyzMTS`H9S5dGh@=l=+EF z7fm0={H4|FFt4`qlIi?7iMNgeT+%-2BOEV-PqvOH#4&GlR5(B0T6nm z-Y!kA_#FJW)%pC}_sh|vTy6KEsf>mGFCafvy63Z(;1ykYe~Hh(T@&{TJf}6!y+QBS z;9l0{^7rYsokn-E&e!Fhv*;VhlYedW7W}9SpJ&DA*`7=D4m{C?&+p-IC=KI?_eC5wuZQ}#Bd3ug)JlWqz10QOAzQ8?XwA%Ys zbu{z5-D_{1f5_+Mt~=-ib*fu`mk)bh(0Gb7croidOzyiL+dJQ1d|qz5)h>`PVx7;) z{bC_E@RHW~qWrnT&^PF1tn*9x^QqK{I^@YGrZT`Q%KXdWYA^WJUFLH+)RFUJ!`gX( zw=?IvvhR3g_5|-OJ$i!L-}@$o{5+Zem3N$p`6V*%P}dvkER|m6L^AMLr#fsq^XCrf z79C-V(ntNrD=6p&vd^W93N0*|-uFJ9m6%kP|zW7zV1e+_J#8tO#JdAunyr2&tW z^LS@Pr3Fur^K$cbO$WYK`iY$B?Oe>uEj0suot#fQ!aXD8W92;9^vBiyCNPtk=f7)( z%=^9)&c|n>q@O?U1NCjs2l;lWP%;bnO6&Q^{njV7Z{D31^7CZ=S%z#%|5P!%>3lsW z$b5)H4#-cE?%V}^hV-Z$IU!G;G2Rz^u6#bbt9LG?=RSkJNY2+iIxjcmZO;e!c5qLg z2YjKEn8$nP3Odb`oEVxH@=N9OzAYcszW7dl$Op^kfTt@I_)p&dC*~t$og1ACnsxa0 zC!bNXFx0nwFN^=)_p#`Dm9S`nzy^AY-`(<=` z?%yV)1msu9?YTHzDR7!EI_S3AW6GCS=EpggF`aK8nkTujliK%ADGPO`%Ju0~qa655 z>7~5NgU^=Ur>DJ(d6?-NR)qXKInQuPqS}22Rf7B+IqzcrCG`2yE9|HOdGgrIRl#Zg zPu*K;e-=><@-*MWKUWR#AUU5TWb%LW($#B1o%ynkw`VP-H=a`)oaV8-tzQQ`RMu&? zUhO{y)P+1Pcj%XT;NxVS)Z6QWPmn$_Qv>k%az0P?n`-aUp%LU~$$VMg#!A2O5IsQV zYu0H3`MJ_ZN2`6t@urZUC)eL^b~A9Aucb9@uJq#T&}lx{-Jq6`pDXJ(_^kF(b{x=o|3z1e?fk!%*X6QxBdSbZ+}wcYzukvjStYpci7nbJAK+iez~mQ>6qF_ zB<}=y@qJ5+ZtpZd?ZFVtlcztAPUqM51)ZTj`TREi;B;R9vjv^zYgMe+74kF>_SR4I z1+v{3zqcFY$)9EK4jwG?A^P7+ckZqBsC+#kFXmNR^y)AE#A!S48r&P|&~ftiEIOUn zM??BRp1f!4zTgYxeviA>4_ve}nlUl5ztXq-Ko{qQX0+Nf0P^H#Y78=+ALoo_~t*?0wCpDZWQ zXUXHaQ-cYRpDX>udbRf%I1%zQWIjpJN&m^GZl4T!I^TUqO#!F*!1Hgay-37V$kX}^ z&N3Zb9RHe8_Yr!4x!pA5S@aCZ&y=3UWv0?YX3qi-lgC5p`m>dO)oTto&5x}<0G+OD zZ=27Byvq6UL)YK-+b};}uFsDV^PmoSM43SF$ugg2|9tS7((CywP&2A?hSH(sI#N>5gA5#-4qCl3Lq?c)~|3NE&fW*lo41}^rOW<+LK3~u}X3;ur1 zrQ!e08#pY1Ize*%U+q=<+zCq|FV@GRf4eW7?_ctmqst&q?M%x2k>IxfrRC@Q#usXD zp0FJ9w*M31`IE(?z(sxbe;fc*u)>lxEC+E}ZAdPxeWGd_2>I1-3CtQOR_WQw*QOa$LE-$+aXV$={-7K_ZN=Z33*y>iE_J?-YXP+ zu{?jPbleSjTJEGfd;Y-1dBo=ZF5heB`EeL8>(6!B2X)8`9aeix{R5B}?G^h!jwA=c z>AZe&3f=ZUM0|TTFLDU-)DLk?^A9-n+xv_}r{g(E*&~oAPxyvDTb|dxla4`tj`Y87 zql@;0y}uKE9P-pY^l(0*^z+??^X*Leluu4Vp88SG*Pc>(RN;i8?3_xmEMTmrX!=bZ0% zk5%aO-dw$0S0GQmbtbxaZnNktYvdqpoA`^xpcf{H*%}Sf6OQ++X=mm(R*PIX@1? zxT0nh^~d}wbNm0=BMxIe#2j~J*AY9PFUMm&@_d2yStz~BI}12{&*4v99MAUoxpMq4 zw9$8{|0@rXd6$itr}v--r1=4LXguLpzG|>KZ|^kzVDI!kW3nq)hkQ)gpXU1T{V48J z*mcm(shvp-5qaJ}StPG7nJvP3ozVZ_ybfJYJB-o3iRYfV9D>a{pY&h(vD=szbw268 z@)Fe?p*~$_dqtwtefO{Yva=J^5&sj%uD`J^rt|F}{wEII*01C9)py!kX#+TA}M;HBA_8#Bkq>v}K#CU^?|CO-l=ldm7 zx_%O!&Wla!Q$T)}>~CaEl~U=mhpTX2W{lm=Y%i`e)1Z=#EFSIu}> zIW6SLr#YqrUu&Lsno;agdT?=mS@eiH8BFKLIr)WX;rw`}`{=m78KI6C_p|6PUS$Fo z?GO9kqIH?U#ke>7UdJ#WrH5WX7w`YF|K<6d1@h!Iwq*sU^-s*04V>DCrvIuvPgHiu zQ$M^{&Ya-lK90S|H(l++YWqTdg&gln>z)go+WC2bxlQNWUp(()?Z8XneEZw}mxqrt zgm3bLI^z2+?Eg21<^dP|4b2$)DKGd6^Et0(G~b<1=`%Cr{{s&(pD$}h-W!+?F`rYj z|KId42zBUrdet0-z{BNp?X}bFU3`bCVXeZDr|(n!bGit)?Y&pt-u9SRROx!-V&FmY zdH3Lr_AZ`lXALa@`316n)#4+1p!EE^NbF!@8YjsViFZzv|;n}kmq4HcM};n-i*ad- zem1xs}G%9 zH16H@HRj1J>zYFy`dv!~v{1TnQ8?ef)UR8;uqEVe|L@F?=d77pgD1%T@cw_%>AKT- zX&cCspVIyUr}3gu58Hy%IK#u*?UbG&TJ6)^+nag*yXZQV`?zr1b%cN4mH)1YCLN(Z z^*1i870&C3@kz}X(!Uer#q~=w>XqyaZu{Roew~`L)gRpUKj3`11x9qSbFn^QH-z)$ zit$y8US>&G$df1L=ngK{ll|Y;19b6!&Fs59wR%9Fyo2#Kxadb&bcbVVUpBKR&ZjZe&}N#$cy^yf4H{vReJfc{Y>ZEO|%1=G4Q%@zTan<{aDTL z**gI0Oq2cFnb`+|i~gHM&wF3($D;;8UM$z5SIsvBT#PeY^!v~Mn>T7S6zWhv|F!op zrS}LK4o>6wM(YvaG|pLJv)WUQ8ENME_M~>8#g9=?M~u5_#;4t*!Nu{(zSl8j47fNS zHN)wi+DG*q2YH%TkRk7Q)A{;{>#0TG`AoR&`Yx{5xf@P^I=25a;^*Cwq!X2Xq39%V zahojWZjLtfm6vi~tmJ4NYVBBp}d{?FIi4ty8Re;2vS!D*1^-|?`uSF0vZ z2dD3FK604}F8WKFvF8N3_}(VFzWdFByr`oYK~L50IdBf-#kdxm*H!CP)GFh+5d3eoChwxBg*dgmj;5<{ldTw^G)a5ll;ruue+fy@UMh8J1v0S#@ zoEIv6(Q$OK{+e+jcQE9|@t_&Qy+Xik|EtUQ`~CiEpHetfnQ!|UUDUVefz87pPrhWt zVsP8{+4yoRlnGb*>LuuOoY!f;1oGtjA1norkoR|;S4Dt}>!3wHWh^tD?-%lZ$At6! zLf6N2b(cdO(VkfJX~|cB({(EMd31XIQYphK$p7Al3|HsvotB%rbTrf_pAe2t-^(g6 zVKwB*!|tO~J70EXtTKPSYn;-XzK92>=Pc9ftWkQ0WC`FjUgU62?UkFZgFMwwn|?hw zjbGItruLi_5|w#-r|V|7JsTh|`U4i-b^J!9->k6-oW?&>M4{7o$jrQ3ATRo*?7sfF z+E2u9Rpv{2Zvz+oaMlh4ZdbTPZ`5js(qE_D3BE%1w*#-L-RQJinYZ`#avZF}ZjtBb z<5uZ6$L@hTY|7pAPQ>r*Y#&)AlRf>6zN2;tts5MSnPB(L>;LUhI5_PR~7a zw>%7a@*`>f0k?hck++BLp+~@Joa#=8qe`Ecrc?<%W;h9r%pqjz9;#<@EN5Cy;Xbh4QC-Q#u?akzsz}Xs$c(`+PAg80C_Ra z!2W;J=OQ@OKey`AA2{^`2X?;@x(<12 zmwLakckz7W{_uYxPyO&IrEh{$dzJI6aDJYW2e!KfdC|_W>wA?u;FOPDiB97Nb-LY! zJbkZn^HH@w^S!6c`zPE7r+FhG9uL52x&6+m-Fv}9$cM@ARW?rf7(7&Z?(67_q_->e z1oGrRKBLojGKY+R2Kkk;PU_0fl|C^FeYwnM>-qxn^u5in=P&=j>Gzgh^9u5`p4a=l zR(j2|=(K&_Z+Z)PI^O(Jyi>ZvHS{I&x>LO5d&rY-yZaG5R_0$;`~*(pX20@7uAd<< zo|Cfo_@;jW7uRKr9$o7zIOS8sqtkJ?q4zh)|K9Vyw?Lkb+Y=kVE4@_OAExv3l=4@u z27n$a zKAzyTo&UbA_SjBIAW!ut=1vMu$N!coY7eOA4S5>>Jbf}5IL#~TACMfJ=A+hZo&ucq z`{=~~=I=|Vf;zVUgXYJ>!msFbUsilaYRJ=f$RjhS0beHX-y1lk179rXDQ)Y9PXB|@ zCRcjMFOkoK=1x}oz2><%0uPn_ z#%Bd`E4}4>bldmX`SBByA`j$goT1bOdl%z!-iz}>p2icZfBqAve*2pn`JoPt8?>ub zK05OF8x${gK1?UPyZ9~vSvA@&(+I= zi+K@jK7TKCnqLv!sG^zY=R5VcQ*0E@ud_6cv2k!Es891qn-#CD^bd_wR(0zTIf*ruA zz54b_?S<-hgnW#AP8WTl6S#Ok#iG|**cqJ0SD!TZ2d8nNX4}-RE$s?gBS9*1a0pRQ8Jd+1|)!wewAjs46jvxA9 z)A@CQyk~E_gCLxd)05SFTT&kHYzKX+F}8 zF(aXVknE2oyRP>2i$_6zw#?7yJQ|$(b;s_F0jK5mj2a8RNY=UJHcsioj-b;#@CI|o zL!SIq!wKL)@;I66J<)W&zsSE063({|jVmrLHVNvG|GQ~2_;gu+i%#!Tk61ke^5hAwGr?(|`CrdwDO@xDX)s&qMH1ECJ?R|C)BBtH z8FZR2I=^TFwUx%%VT;Iq*?`e8lS1PBpO^C{}%m8yBKhKZ@T$DwSSnfTAA-- z#DUZPO>#`_2{Yp%Puny7OM4gNa)0$pfPA3ruUZPO1*iV@`HyPXo2@hRyj`V!Z_458 zmAyPp#4xv%X8SRWeS4#~bz=~tsRDZRSBS?Ldtpwqbb)EQeKPuu5* z_cn0RzOi|IXVIx0b}zmi@-!YB_d)H?hwg+tJx@7MewWfehpD~G4|E!b4E<|2)FDrH zZx1-Nd+S&11*h}3Ntb;}-~A6djW_m+KLB|ecfFMKpwfq(QG1%eL(2TzwEx(-xUV@l z1fA~F!^<6kJbAhA=yd=4@2;beCm%ER7&!Iwn^!*$PW|4Q%hARELR$1rf1iLn`SPbH z!D$?|YTPMs>K`T~Ij!`Dr_pIV?VlEBAy56N?rF|}Q+@pkI`uyXmpu=8@+?^{g2&1J z((Fm-@zVF*R(t+xm!J;iPdZ-)r}3rgv#)^Dec#9@=(L?P#9oCw)p^_hn$nBDy$()w zE+pPidd1ZLg46okzJyNWa;@6mf;^4yeaUR_>)jMY1cAp}~MOQlA zReFts!g)JP<8PT}-Gk+l@2-0voaWEuS&L5n-b)J}LY~HjJl>$sl+T;)ZFvNF^0cWQ zgVXgleD70m(Vluo8`KfojlIWrM(tArEszhA z+aXtz@8I*KM_l~@PQU9yiJ#y!-Wc~??e%s$e5dELOXcsKQ_>!ij&^n$Pp^;_3vx zN_wb2dX)4sIh-L+o*3r>PWwGmQdgxn>5VStEojE>(`xT9&kgE``!)96o-_uy=nrVd zuq)`|y$sDr8|n^u@x2Ajm}F6Vp*x95~UC5j2?FQwmZb$)n8b`V}D5cUHUPfOn$6H=6N(FhkUia`x z15V#-)o-JVdCnGn^|G{(r}^Y5ozp43#gz0`Z_EC-i;u&I?*`LJTofYu_t=4 ztTXaWCRncczhid3H_NQ_42kICx~UmE2l_ysj>83|vw(~Ko@QLwp4IBM-zAPuHlIIp zHl^RbiB8XDCPZY1JdOW+@y!WN_w@xPqf zck9q;I~*I92lBsnk1u&4PxCI~x8(yD?F0LN-c0$GzV%;pF^*x;3w13BdAg52o3ju& z&37H}5M7M(SoD8u7lu6f%mPKN&c8E{-vRp=sP^C{#UM}P6(v&?H=UpFV*JFSuMI2l z|8?8(Cbo}e-2Y_D+gxl<&6vEkq_xif^}oiI0vGeOG-HWF8E~cuLq0(+ z_xf|SHyvCT^0eIDm(|`Oq#oo&KiQ)HY*ine`lZ8eHvkv=%c94ZZ>aRNk?1t8RsRP% zj0YZS1a-)})@TAw?W(I=Q>FiVQaC>!={Z;Oyv-m_?flHd=HT?+N8Uj#z-hV1i?&pH z$wk8Xa%n!mo(03ryuAmv837*dlutYEP{ctqN@f@d^+kVDGkOn4kCgpba$Eaj8-HW% z$Oo3RyISq7^8`a)Y)^~+^{Lv=u3rRsdd}sVGz6U1XT!TtaB)4d=sqpO!0C7G zNwZk#9j~B^evU=I(qReYf93T3nW_gcPkw*WQm8}sFO#c9DBW^=890qME}0!^=VJWl z+!M90UbkGCe_A*SoW5T)>#cBp{E&Cvwi5C*uDB@mDmxeNnSOVQ`va%@4*NX)@57K; z@lYpL&I`5oIO%s@VqSc2&7#{o&D%^}Zw=I^d8EJcfb$8EUnS>f?k}{~biUolAB87E zK2GNCeT`iIrVbmR&Iakz+X?6A?woLZR&G+}?VbLQqjI;+kdK%1b+^6P zqV$(_wwlhj!v>i@lx!R1W2ARGhaM*H`&<`nhdkZS4*aP04a0Uqp5|RNE454Md4HnE z$>nxzyBqR!pYBz8FZeP!4lq0loyL39b=?Q~-}~#l`yo%`z=hWw0H^to=lUE}dWEy- zG|qf8@DSu_{ad8_2b|iaPgl{Y9qU~F2;|9~exTDl$!g<{L0&v((Tof=j@!BI{U&yw z?s@`TT&FbSjlbHzPdlm1|NRVIJm+QC{rFRm7uN;Ni0yq^>8a1C{lLbvW}YAC^n9&A z+H+8c{PY#I_X|C*%sG{Q) z`d7he9PDoFpLo1HkG2iC26f24zP%1E#-lW2@rE1V;{T5{!!^ynN{_yb9%;6F?7JKt zZb3d;`W~O#O7D0ZUHtD4dmq2*9mtb=9l8s?NVa45XWdi!lzR8U>AMFxy&iyz?*>@( z!zb0gB3dV>-k{TW0>@o?4Ef*ttglZXPwh|ARnJZ5$GOV+^@yIYQQqDk9C!is z>3*tlu9x7{ZtR??_I7n&L7wiXmY#SGF4`H3K4ifgrN_Qjd+KIyAy3b_CTvmrfl}|y zJl{UFT>me^`F5aowd~#xP@lYirjOt>Pc@_CXYh6Ac(rCs?}{$An`Zd=eu2E$KALg# zq1r3={RVkzH`bg*7yC;y>d&`8p8RE#@8DwnH6tL!52bgzhMpkD;awO1ggng`E&Car z#`*WGaQwlav)IPLe&u4km;E1^gA?S*J9a^*e!iB|8S*r)J0ji%oSu`W>*H!VKOV$* zmqp*6-wpC~KbSL6?dOIVkQePddq4N8+HZ!sEA#d)#_=rrxHcY;7x%vw{nH&!a4{~b z8Tl%Dna+Qgm^Z?{!{eF+^5jv6(Zzk4W<03rt;k#S#&N>2zD0l2D;eZz{paUPZs%ft zi2KGA;50uWc5q75dHoo9ol1R0I6qIxw}hmEI`o|7z|Pc4FOVe-IQ4r^-&TA7$h44O zC$A&dozp2jJYARXCZf}KfhG>}fjoJe zGFgkE0B&)nNH zmz|6I?u0qHP3Omz=tr^n`OW+wPriLUx|sK(8EppUfxNh$YR0)QdBLeaHfVc3aPgeW zqNf~{U+Lp+qSOEW%yKLUdGdS*(CL05#J3RS$xAr)n{UyUvdd0PL*1&e}HovE|Y zslU;#X)(x?|430>=@l22FrDugy8lYuwj|`q>uy7*_f4{mF9mrT@AcQpfYb9e=l$q3 zp4mTVS;+t1y&qy;jCZl?exLGChpsPv1uB5kxP9>^6~XEGLeI5ozgwa*#K> zUi9nOcDvpHoZhp(71>bf<2pA2Pn7N6vqR`xr01*I1oGRZk9^+j4}6zw&mS~w4*6Yj zJj&j;OP}i90`g+Ki{0-JvUl6}gf(M+v6hfuXO5?_dHWxP^Zm6}dfe#NkWY}~Z>evi z(|FyMWo;l&-m=qQNi7xIF+5G%~4v;6` z)u1DIklg<5lXe29|KUA4P&nT{Hc(X zgxbqZ?hbkJ{a*H6jz_}zdXm?Q=>d8APSN8Ye}mI^kUGBZDLps4FSA{*HA7q93tU`B ze(ily_J+K;erbl+MYT6*(O2eqyCM3ezM1+d{jZVeVtj_Z?@*>cxYZ_ zDJKmCr~gM7_Yj@lv&#}a2=e411%^n^<*-eTbC!Cg_N!}$LVlCXS5GkvociGp&Z~WD z&~Ta04dV>H%}0P!Kj%k=k>K=Rf6)1?Im(ovBdYbU5 z(&;?1=r21@1E+rD-0ahpesH|n%jq*AFV07H-9MuCF*9aCp8AbXo~wP~**TCG^T#Z@ zRy+Wl?)R5}P-LoxF zdd!L-a50X}?(2IlRQkq$&}n|u?0{g%(|GmCq#@wc&*^gpeV%+yU42m~HOFiSfwfjabFf8FX4;FRAIgHHYAR(~&p{4#U?twsOr z7YV*XdjIvy!6T)wOtM1he%n`qua@~=dHXc0ARjM(@6XHVtEKlW8x47K56hqUI$3AZ zq}5P|`sdlJ#hT8~3o-t~{#Q3zct7Ui`30N5*CP(A(5Z{LSb<9`3w#d-bz03ytc5&!@I literal 0 HcmV?d00001 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg2 b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg2 new file mode 100644 index 0000000..44806a3 --- /dev/null +++ b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg2 @@ -0,0 +1,22 @@ +[chan1] +19 +[chan2] +46 +[chan3] +2 +[chan4] +20 +[chan5] +1 +[chan6] +31 +[chan7] +32 +[chan8] +17 +[ValU] +0 +2 +0 +0 +0 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1 new file mode 100644 index 0000000000000000000000000000000000000000..5563241971c4a46b616297e8832ca160e294dc39 GIT binary patch literal 133680 zcmbr{byyY6{|9`|EN~7T7K#BX*qzud1~w?RqGIPQ1_mm&Vqs%}jo8?oCz6fjT+eX*p%XI*cR$i#v z4OH71@ELur=HBdRU`NPH@0z0%_-c`F$h=w1($2!WX#K$J^L`lFwTrI1=j#f-Osi9p zO`fFh0VBh7^GENX$0`;Ue7l~k>+*4sU#jd8?J3C?{?PZBJQH;Dp{e@rzAhZ{E42DW z*n!}Q;H$_JrcVN2M(ydU= zq2qjQ#f9K2$jAKF_uNMo>E<)KJdZlpTmtRcpw;(g6--OP*OK?`uJ5&rM(O6m#B-d*)oy_fm6LKz=#(!-njuV7t-e4R4{Zq3ym{5)FB|-GFp`S01f~{1UBB zN%mjfzup?%ypwS)_)@AsjJs!Oi0& zAIHYIZPN9;H_;c-ICgAt9OUJ8D|v1KH}_j0E6JW(ZPWF{f(LbdYojx|9@+nzt`DE{ zK-W9$e68y>uYc2ZhwVyUe!Vk~^BcAP^kf-*o8~#$@GO>)-|;_sG+j?K)``}}pCT-7 zFxFqIxi`z6jJ{GECyTH-9ju`}|K-cHd`Wg{ALiH7c-te?2z8`at7QUTLE9aB+y;Cx z)z1@dtLxLBqOYPlMc3OwUi#`14&d^0)p>`$mfF9yr6c5}H_Pk<9z)xmJKq^x*6-Ec zMb~X}xq{36H8kvh^Ss?Ni#q1{)kbaqda~BX^u74hEV}uiRDE|&%m#UR{ERB@1|CJ% zyPR*)*U|BIa7%W`OD|W!9efkDf8`SN&E(@#azXz8+uu~rt($KfgC3*RFT%bD<$?T4 z@)AeU*J$p|tfzQDUb?SMUU0d8pPk9*f9Au7XXk_bChffTWNEL_H)!t7`VRGi{2FqH z^4_{0_ZfXX<+Haf0D0-BsucoXspX5X($V^UxoctFd`4eEb%x|C0(p5~@N#2O@cCN4 z9P=<017AS?<$}I1oK+n1az1v`xg@w8f3{xN_a|jbK|YGcrHP-=7iwOP{TfvU@=JyD z>(}zCWp&+lmA-H6QV#OVX`I$JQ#yLevTM{TbJ zc{#3bbE*t3$HOVf`d(;Z70555{_O2mP1mo5>U&6)>X477{_#_4==!Yin&6AI^S2}` zQMVTO3Oe7HuK(Y>azEGS}1f&ezc8{qm{+U&t@h z&Wn<4M6L$9p8YPm95E7ctT@vd0%6!N_ z(Nx!`zt#7mTbn_CG4;dwfz5Tj&es;;ODX?kPfKvQ{?I&UD_wt|f-djBN_PyPd@Boi zA7Sg&TGy+OM~|g>W7Y<3ATPc3*+BA^7Sg{?YYQ&#n}Xix`^Dz%bn}*5(B<*4ZC(e; zx3ZAOVa_k;bG7~~#}4l92>Gd+S7(LXJL!7z@6O~cEzJ9okDoPzx_~bfbplyA_Ap;p zUH{&v8@Tzr;C245^Yta}>zeg|{7TXPeEsLyrQrV`zFw>2&8E)nNp)IT$o=wPK7Us) z$jjp@qsP&4;!v#*MzpzA}54Ak{| z5$LG(UU46 zx<2U_x|~0F9~uUEIsac?cLexq?Kw4!qf8+A{Zdg78>PWAZCjwl~+q0*l%j;~D+Or@p{jD+^T&^<&PoD$6Ry!ZP znX&O)T_3VV-!l)Kr<iA_-5CAE!5enxhH#AWF7c6&2zE|uh6%X?`g0e^3qFO+5oL< z;PSlqeL~-przhymab-j5eOatmC}XU>P+ z2A9V(yQ}XvSEoQ;&MR_w-v!@9_ec9v^?h#KJ;-mR{At_!;PQTa#U*{OKKB9Sx6*yh z{T2_w<$X<9rboK|Dg<4w4=--_1oCp-)$*{uuNj*PdD;KBY|?Zyx{UbB*w# zUv$0QM}42&?5l1*INLW}uUhUqxSUt7UxY5lhX#Q^ATQ_J9v(k+z00a!;Bwq!UDI{F z|4DRN-*M7!$jf$4axmok^PW;G^*tr?{aO)L-~zg=Gq)6jy!0|3&}E%}hFC&=BORZO zDp~zc{^~pCW2ug38*8W|kI%*TjNp-4z8v#eX#$t)x3@am=z6^q=yH7cxZV!((mUGQ zgRj!ITZH{Shc36fVx|M+rDx0H1TNPb_gq7l<9U7`XUI!G@eN(xKbGv^0(t3Hp{}}q zr)DN_IWD=b(D&!vGwbFHKg|Lz&-cW2S#>?Se>QMAE?rMVm*aoamf0aM$Kkh`a)8V6 zXZBTeng3bA9rDuG=gb8zx0^ZafAjbnxuK4H?&)vH11^vA^HV&)Wk0Y~ba~y3S?dXT zS!ZOwyt=;sO+IkhKkc{V*Y(q`Ub^1%3cB3C_uCeLygZ)AxfcYN*NazU^nGUaLXel| z*Vkx$zi^~5;4nbw~OmV zAj`>)KGS#ByY|28@b_B#pVxQCh&qs$^TRDo>VnJnwF)HH1BdtM%G3v!?-Q2!uJ2d( z`9fa4zn7&-LvZ=t@+;Oz*F8Iz&K!Y2tZVCH8biQ>ZgnF?%`IF>`ZW|9usGs+Oq`E*ZatwjxO}gBP%3(iw%sCZZS6LYU!%D43F#p`E}wr;r`CO6S#bTb#@B6d@lL3dS}Q> zkFw|jZm!SYZ@)XPE4aD++~a+Nblu;k8@Q}9tp~cik7jSWL;k;9K9`Ky+ynBmJ#_{I z>-v_<=<>N|``x`DFVEkE9KChD=WTTPUaL)1AIM7|m#-hVtnc|4UB0h7vwnZbOD}F3 z0501_c_E#w~PNXHS=5keBa^2Id_OZoZy2RIlUT9->dzp0iY9-Rq5jyxIA6-?d06 z_+pXgXmIm7KfnGamKme#6&9kK*YSA1Z<@Z_$B%_N zGXE$v4BR~5-+72^B^zB zfzVQsy8bj$-zWRehrC>OjU2H+*MFy=%lo^P%N9aj`nB)q^8S6((M6D#{<_u@aCtu- zp)3WL$6@!g`o5$<6y)W7rR$bu8QlDRG;g+Jz;bYTJ#ATXg{~i-r|*4-uY$Z>PriOz z--ky<>*h1Mybii`h=IJk-<HX2=bD|<8HbP!{%&u5)`CPcpuub5b#r+h&FL+!b4m_5;+0u+|{vMnsOUSz= zLtfvv&~yBrk1#LYX7yI6vs%l0v!TXq;PU%%d0%Y@m-&DOJHX}l;EFr%)b$HV=<@q? z&MkI9ex=r)A}lms-*blShWr|8|HmqObiM4My&3$^_wTNa*_Xlpybn|Jp}udbyI(i| zAR1jhM++=`5c2YS5Z_FV~-6lu87bpLhBPbh&=`+5a5m<#l0U*7Lf4W8npGx!o-tFM`W{d%R!Y z4~$QOyu6T?r3PFr{KWKW~e&EqPchfL{kOE-UDpT4hLlLGmjTK$~t z8oQ(GA;xq0Y3t`hk z^u0)hw~+S{>oCDAl%4y6UP$watj^f?kS`$CmqOS?_N4lU44$su?{IGxgI+>0dl-8k z^bzt^G!JFHUZx2T63=ZLD<;P4gfN>;pZ?|Hs#ml54Bl1b!&xxPnuI<~^ENDR+84-6 zw=Dl1e7tB+IBUW(`Yf%Tb=ZK#n4cy3Cxrc9H?M1jsQ0I#2Y*0)^ZO4W>T#8G%+CzY zkALn5tNjAsC7zRpsK;l)Xnnsn={Mxf&t-zuIyGOQ?-c93!D^ic8!htl=dk8}KCXFA zHolJ`gY)w!SUvB)e_-H8#D3?#WQU^b*4Zro!uM)*JlVSInBOk?nYZ&<8*8YuQS&0~ za2_N0Ci1jV=v&F>S2aOiy2D8u@YUit30C)4+(cVlZ&J?=JX*__V|zECFVws`D^%pq0TE?)6 z;pkh0Cs-b39U5hY{3zojH6LsFoGm|>4g9OoRo!m7;-yrZ=?0$H)I{~QN?~Q=JM^BW z<*N5pe3VApvO|8fsjSk?BEM2iIabkKcy}?*>@$U{9?pD~pl|3~ga`jaO;M>H0>8KoKC9Zmb`wpU_gMCR#lW*@p2YSQD-Lci+^F`O*Btb8vA;^I-ov#d^Lp1-+_?UmqW+es^&x$oDbvaX4M|OkdE$g}bZg(UIF_ApgOXQ>|ao@)|2z zy)3x9jkoHBEbp;}(fWSwH@b(&rz>w*za!tOVX(xKX*sHvUr?yu7HBMfH1^ z&^^U=vnaRNu0>TKpGAxt`Bndty&CunBk$*?N)lUi3;m&y&;L8BZdt85PWgomm=>SPVbOV2r}rf~E4bH~WnJ*KMpgY{~Gr-ow{2TGP7OCgy!|mv=$j1+<2l-SZ|J|giYX9UbTOa(cksqI-sxRH|3w~6L z8y(g9Np20mV~zYc4*1RA1ak6h4dXlwyTuNkZ z2RDcOIpHso%*hwN*a!Fj)N&lK7!gY$mNqWajo8J&;w+>dk$5P9=) zb;deG^>6CkU>A?p;HSweK1Dxa&CgSw?^mY{;KBs9()sd_4yryo9C;mt@${^^BcdSueav?z`fDlj*wqvT}Shm zBRYXckp~&NfX}n$*O3&ap6|!dXIS%boBKeou8^Na?wpDqM&3NQ8|253A3Uq?g^G8F z{9rAAjh%UeKAb$gQ4igG$dzF5UX;%k*%Q16`R-P|z`K&?+oSJEQGFoaRLdu^$`*aW zTL^!l_FKvk^roVpxsM*#5AwcZzi>}84FIoB`T1wiD_QgNg69pz2133f`Ks4{<7FuS zu>WAFQ}D+wD7*F zhbZtm-f>x@jjPn^eUE14%O z22V12s2ID&oTeom{s*Bs^8qQ z4C+{l`_N?79UYc~8%*2P_9qxxD3c;rfWJ5L>vF8YSMl~=34YtiubZ)k+REuHtH3Xb zev4AQ^ay=lo`QZr^naA0ta7hI4Aki-j-M!lyVB?=dRrr(k3|_=lvm?cL%xxi-$ohi zl^M0xfEU!Htl2y{I?aqPuXiYz)pmrzqeWasQHux^vhO!T`0k@ zn~k2j9r8!4dJ2zY&t9SLu;S|m35GZ}|I$v#FSTkVJcgYu9uGcM^W|*SC-hO8FJ`rN z?Sg!N%_G@_3VXoY2v0W5V(rq=>ygiDw-@sHG!J7x4(WSv-hI0H^)dUwlP&q@N;U+u z2Hg()%b%#e^ECQCk$+<7!2G8ig#2>NOS1D$hlQJ;GX`1metu%e#`-0pSJ&KuInFr( zd9!~sIIxjv=x!qa(U5M~KlB*nlNDZ{v2TXCRgUX=nP2Ez6~2F+)%E0_ZBIactmf6( zy$2_Cy;0p$!p-*uy_JS)ojj~13yVhYuK6_P8FU)*W-rDTv#*5`z=M<;YMm-l!Cz~h#CqOEPZiGB&92mZ3i%i0VcyTdD~scyuey%( zYAX5|;T_d=su>MmK>ojcp~yE?=YhrSUP6Aom_Kl@+wwK|1~D(KsjhEl^cdl#Ro}J? z^NYz_40{80W(&`+)}QzFE%*j8&&i_J>3#Se_--*@;=Us1d+4 z+egT|iS=irns4Kk2JU6z_t8dmz4ycvbPutf{EfX~-5Pv?d|ne@zv4cq-)CJQ?}>u)hb%fDeeFQP9L{hXrq zoBwPE`Tz3WBG3Ezb2A03z`5yO?Khd9wd=}Mn z9JGe~9rE_oOyKWKe4H<>9_MAOY{1h^e7?%pQLmgtFKEN(?R;I`l+PCOeQo%hlhNixW2d_>Wcd^{%7evf=B-+*21iL*B-gw|}4I0XDgf3%I8(|GWvR|Gwu6+g)kP zKUb>iD^_L#KV{4NGfllOwzJF(o@&eYmtu8*#hyU_Xv?2dxTyZRZdS-!+41LY#Z^CH zpA9^>9iO*XQTKF31nE=hy3bs$aj0KF*%^&pfMBY}kt2kY6tH(W*aU zdB6{de6&>}oBh-SJevcb&uq0yVm}&sf>&|i`(>-uH5O%;7red5A6C8UIrMPNZ?X86 z`9+@3&;2bfYJQLTXZ8YrBkEtUy2qvsLeJvJx68dtMQ_MgBxm3B-7!}|Xy;rj7TJA0<0hmo7s6@`416F)C_ z{(iq=;QO5TdBolGZE@k|ek@$uC?%pCJ5&TBlyS(y-l! z&V1eGo9dx^(2XwqIg82q7E5(619|B!#-f*@{Kx8LA>YY`KR4lZ${sBTe!zvVBV|?H zZc2IZ8!r4g1@}$96~LLR+3&FjYtY?Z z6Y|bZe0{8fy51FDzZQ6^BmcbIPbSm`Z{o;5Zv{hZC2Ce3@a&qmP%gbfzvN)fw@}va zs0;Zun)@j)%GL+Zrn#>&?GyTadvl%I%I7w|kl!b~p1Pj4AbSJwFyXBXg_YXr4Z-sZ zk5$*fwsdI(o?SR&|LyK4Gv*(w{TZ&#i}p`!40YV>&ClcBu%5k}fS*hZZO(9>%j*s*Fdb;5ydYUbtx1_4q!I{a;AwSrTdrx)zFSf7+_&z&6 z{!diL`L1c`ckTG~B3jK?+TRlLH|+Sj)?w8{tN4QtvTLaNH}$&a{tJDUXn$7g-E4B_ z0LUM)E2`$btPik)o~^+R_HL?|QGH&xzOPu{2J+cOzL}b@(JK(#NWSSD`e!@-eojX< z-*{$Q$Y-+W-cR+Jj_tw2gpX3a`!)2LthKz@PP?ld(&=o@;tJ^#GibKmU*`6zq7 zuC-3}mX$k$pSI`gKkKZIvcjwj_ZP{!5N@6aju4)%?)OUrgTY4&cQEc| zRj;BC6ZN^@iR=maxx(Fz+nImP-op91mcPYaQKzEnpHtAElP_G>2l8JrcI{Wb%@y9f_c{ap|AG34#f>U+;O1EJ0? zN4|fTsc)~@Y#9W8MEEAvHw+jIojt_=SryerJ;u4MFPuKf5MWj$4C z-(;?+Z_Ycr@_GAKtEtMei}S!Aih1l*Ws_o4CQ{e`S%ALTg;62qzk`} z%u~JLPW0+7{JLCB_2MfQLVlMszaQcA#;!lnhdJ~6mk7f=MLD<#@;=VI{eui+l>r`$ z!PCTiGRQDg$!WC|ysr~~kCe|R51d3V=5#>y3aS?@6b1R0j(onv=aUJ~(T_Uv=fB)* z3|bEPQjYw+Wj@b*P-+GETZi_-eU)(^&=VZ^^WO>vALX_GO3440PZoJTKP-NI737aN z@cHl)^?9rH@@Vi*j$hUG7h_(^+Kw^cHy!zW?=Z`%xE@3Ia{8{$_nIn3rDg3kkT2-S zulw~Z)%SRnwctw~_;Iz&@(oKoiyklTe^XVrD7X&t&BXnwi`8QmXTL!>f4|z_VwNMn z?--`ur@IYA_i^O)=c)JgdrNMF{2K?}o~^2TMWQ=8@_A{BI&Zu*d=uo43D0VMid9X~ z_rX4KkYDA7ub`8=vZ zk8R)=ML+ZTQp_3j`HuX&=JTS*4R=8PoNzv$nP|5YoH_CF%|V@qw7-mQ64%qhs{fiB z5BZN`K2TVlZ`kbK1^!%kkUD?ZFl;w?ZgHI*tX{_(R@?(#SzKqsRQLS-H(pcZXQ|iO zFFE%?ol3&@sea)RdY}^@XU?cTVD)~;4;Fsi7|CuK4}gyn*Y{M_C%-xfK3lk=j;jY6 z90Fe;>bR;N>3kSGR=9_HT_}4A{g7y93Dt+TI12exk#DQIU-o0*FNOC~-+~?=g8oj_ z8K!#P3dbS;R^%tCzU0se@FL6&T$Zl6Z1^Ei(^<&U|#C`f< zHJ>&)0rEqg`EhkYeOtTJOY{jMpQ3uC?-|I4i~8LAI-LcdDC)da-7PH@m^4w3}K)>S5#|Q40e_jxI^E~h~Mc#bh;VbglY{J;NNmsy|X?4O_jNesoe-}QEnbiD^ zjp!Z3b@QdVUz(LphJ0@qejiuW^oza!fL>SlGWBhuIXkXHK3()fKkJc7>3?p3KNIuM zMpk2$YKv|PH{aLX7Cu#7N6OOa7Wfh8yy|veuuaOYJh#E8IyA#CzummQ;+=T^$MWm`Nps%pgT(k2!LR!T?}|ERuPn}&LF#qi={fp$aoq`0*Ov;G zdLZ)V{W4fw2kWWV>kbjq7LG_GYiGyzcM>@`r`DGL%pva;JjN7apWu-!HCs3hw2==O?jh``7k(2EIpZ zcdwz3a{D-XPRI3XKEW_h*)i=o|R2?vqOS9Pe@kR@gmQm=MVEW!LXQ^r*Fk*Uul+m7cXr_IZ>9E+@$P%@N)G&cSoI8k%H7o;z&C6AtA#SA`$zD7 z4)*H&itqOw$Iusw`53RWFzyrN|Lx}EvnZ_-p0oWd@@8*D>xz97(WjHwnfnFu^T{V> z`UYNt-1{neE%G@PzC*qXdFij{1Iagz{VDS1d0GL@eU*kaeu0;x{H_@EhUBfgr9(b| ze9@EN;6yb^Y?YR@2Hm* z{GvU-zvG@~Bl>N7{``gexd=DNU#B`1KcHW*Z=vOHY|jq)v-bQxk=Ln{HHWT`7=oTa z`S;)5A%BAE&)J_7d_VcG+_}K_k}to9ew6y>UgtcJKTO-*Rloy$JJo6Z41JsCwUrrl zJt4oDTsfaNgY)}h{(NNh%zWTm?fH4fy>65I;A^z@)K<1`LyxilQ>UPhH{_*vSb)BQ z>YVw8zJ%J@YhMAV6GeI3(1PIesh#6lVep02p52|$XOc&F7lHf~?YOP2lz56hi+o_u zVvrBB=i?afpZX<=gO4X4H3NMZZMS915|AHC`T18%f{!3CH@_5k2ziq`W&XlLsedx& z2hr!+Tca%0=}O+#P!7B;`H!RMfz+NJUgaU*iSp~#RsionKDJ*)@HXUmiu>q#tLf-1 zX}c9eDnmYi>h!yUUZ30{vI^wukw0l$RoCB-siy1RDd@FDomT4ca4M=g%MO-$d?eGm)C!3stsOVb6=%(U-Z&c|4Y$2kS|N_v$-y~r{>iZ=b`n$ z^N?>XS0CK`oV1mpnv!!dx;y31jBWsV7xMI5`hIOyL*0Bvx1%~CT^m6@6Z!5(jloUi z`Reep7vEeio3OrG3f7r`np3i8r-ULFnJiR!nSHwL@|`H|1)?a90B9t-)_Mn2*8cN zm-S;$qvxl5wjy&NpNqWBrn%()7M|oKhRpj5&rbQIGLewaN^Ug=-HH5J=mN?ISU8a9 zx{02N{Ltcskar}X-+2+Z%qKrsOzv-CNBLTHmw+?!hgM6$f7mGeCmUR>4W&K4X^xdaY9OQ3P{cT4#gCEq~M`=EO3-}Srf2g$;JdwQP zTJ)#nD++9fyliLnXZoIP%MQrPerRmJ6I||>ukYf)kI{C!wb})Kirgy8Zt&CODYyT| z&rv>ChrLio`k`F=z!NB6XdL=p&3%-!HTOgQIOS`dIRL(o@(-sT1ec$;$!m1Ezuv|j zg8U__U$Vpz@I6%D_!)f*`TAW)AusFS$#e|-0@Xi~e*7<7*8kM*1mw5Vb{FJ434VaQ z-y`%xwB60!PD4Jg8WKb z{(A`gx!9zj%iyzY`STjyZygI?0guq~KFYkw=! z{wMFFylQqG@^iGjkJ9bN4e-h2R*P@y`oN#)Gb!(K@D}7}ljnMHTi2)7Ndcch`NCFr zz-66|UC|>cU%t>?$gdzDw(%bL7;?8h_rZs0URHTq>H+v@^8B;V|FpBLa%spT$p5Kd zR;h9wy(iVVv+yzG`;qVL_yl|``KNoS;34E)YCi=Zs=1HCET8>_PoO%{C-m}Vl^l&< zP#u4ZP|EMOe+fRG+$hwt+-;Hr)|gsEE-WAHpLda6y+;C zL3bl}+++v&T;yB&+Ut7LVh-f~7L{%JbDSXcJ&<>=(aUP*X$j@@PDjY+Bme2*1fH4v zpT*ALWyEpI-!sYF)&;zXINrF&=Wqr0u;tG^xqFU5cM-=k_X}M!L*7%2OWb`C2u_jPAx&jp^{mcKt7p>Ee|Rc>;B z3nyFt{lr*x96kq`2@ZBvB%Dc9el7uLLmGHiQ($V;D)>;+y#;lxYGf72XQh0Nuln9NXEn+PSlEg4l-GZq zg6=@xrbczh+me5?r~z(IUTJ(ya(@dOa+e0Rz^%yZtwp!A z(CJMb@IQH9<@e^g;3ln(uX258J#ag6`zz>~ZTa^pct1>N>kD}o&Ha@8?hXD2_f<~b z#(WmqZuh8$P$v`loqUbK-L?K{p){C`o=eNOP@dQKgS?yOe#$gc6YyMG9Y2N5ZVH}7 zJI;NT=w{8pbJ+6lQ}FZpdK`K#?Kp3tZ26}-m!73-0JtTc*YSB$QQr6}gfS z2yRP$E21s9i9EDvJ8&l&mr}Q*+uQQr@!-e#yoDVg@1VJ_l8}aOr2LBg9U*T+USm`z zaD{x_ug=2F?`PU*{h*%joxA*n+f%>I^y~`xOf*gwe~9ix`*CfrZjg7i<-ZTW&#!ug zyMr64{`Q&Zw&X3F^niROt&X3Pcr6&*na0mP3wnY((s5|tx)-<|`LKQZzPEB8$jkbH zKhf=JyCskJg}f_ycaMJHE_D1~nxOA@*7eVjH;?~*%9x%5!2etq)N!)VKyYXJT%X^f zXC|*OXfWh$s68=P(Jg2{)}B8E@@7v~kK4!o|9~qZ&%OPfq2PAnIN|i0AZ z)Afe6M}XVY@fN=}qx1PBukUF)GDBY9&GS#54+zG*lQ@65e<=_Ob4Z&YWVdl=-UmrX%e#CXEDdo^ktJ5T$g8^m>u=MR;enjz1x*F0~rdm6ZnE&p8+o?kg^I=HR4j`Mcf-$pmj zLwJ7E(ixDq(Aue9_wz-7|LOl0%E?FQzs2t_@%r6Y&w~69@%v7^{w3pV@N~`n6xXS9 zz<*Jl7ft8>g z{PQ-ex)A&m9nW(PEdu{@UZ~@M=VI_QsrV7%Vm`xta_FiI zc|OnKp71jo{DTd@ZgTH+Fb4c5)u|D_8vMHrpTF_^&84wF~9+H2|x(WO-d7tHR;IF9v?K^D-|3Ljy z^FT)b)6Z@bwm|-i&7bQ?pyf93bUMzzpFsaX-lO1l$eZVxe7{&fNB>3nF#~r({?GaA zr{pOWpTYU>yzx4R-=izGf4qqQF38LAf8ic|@0+|E@(-vzwU_Myze4@+xWiuXo8--o zqNkAmT(ckY&&f+E2f$yD+nh!JNXP$%X$K)M{e|6OaD}c@0Rzx~)A{nH#1Y8L`OLz1 z=-=r0aT*bZWnQ6uS97g73!z?N4OL{~1@yK0XQlfyTXS zt51QyCbway!9S8eNI;k4_Ho;@keAny;{DNo(Di7^CG>CP7mFuCo%iIUGMxwiqFs;t zl&2x+a{QlC;R57;Qv1`E=zFJh^gngf>;BjzsPlvB*gr!5LLL`$3G%XzU$@KPU+BCz z`SJ?*TW$aPDm^w`1y9q)8TGpFbPfCy`QW6C&exat^?FD1>yUq~9f#`gakP0f7p(IX^5*pfp6_+? z8F(f!Kj&^c>3Ig{*DvlvpQC3N<01E~8(%=)lRTo-D{%Atgn!=RAJ9|9amD@O_ScYq zERHMgm9xG9e?!OHsD*FAe`t05l$IUef&U~=%l#hQye`DsUuYD%d7S6IQuzpZ>Gr|s z&f@st`I;xtGl}^v_lMzWP$x6_-bSCm&FjoO|NY!&@H~|7QSuA8CvDgIJ-YdRkk?ta z?JMM8it8Zn&pbZg!PCSz!~O6#eJ|4C2jstt>m9FuF4s?R^SqI}!-`+v4q~3n|BvHw z_jGW3ZT{Rt$$lLD&;5QMWuo_Q$V-1W75&fpUKgdbouMFqK5M?7J{9eZQrmMm5j{~H zKioIYWsu(?#-#}LeapLV(NBnRDNOZ%VV00DBkmsusn4OlRImbf7vs_(^*QyTujuCa zB!7Obw6})*Wiij>zO{x4Jdf>q^}OTHH*>B=&tiK@^8?+~e-5Y5`^)CB<{i9j!C%?D z(Y#j!J6*rF2|bG~|2-W3yl-efd&nEfH=adL7xj65{2T|!Kh(Uia@^Gk+)2E5#DAYB z=n8r!?Kx>-|rO&k6_{eS!Y|L|Wnd>w=5Tjj_Fby95j@y5N#1M~;vovY=B z{BvrL;WzpV8~!^{yv~S89wKl4eT)axpV{ksf?pNKEzj>W<^{iEV?GWGD_(ujEwtmX zuu^(fKFCYI+%!MEF9?2z`c1iy z{*?C1>Xn5c|4ocPydR!;76DJBItQMiU!(25uUi!IDfGF{*cStTO>P@e9Q*-&-i=L4 zfS;iKl6Cvv_!TLu2bBD*qMxJv>pQX<^ zanku1`c`!ny)phw#9=Q!Lu6l)6kl~h0dHTqWakfF^X zzl=O<`4-?CsXe-e!FV` zX z6mcKN+yAC)2gnZ<_kG-FeL@c=H;(QE`To?-byYk6h4-cY_f5xqd)i++yL5p%t>|-o zde{}*kLsM;5Cq*pV2bpD*ZmHM7rM$ba^ zE7t7~b+VGLUXT7m{7(w6Q@7Uu$iET)d%}Ivi-F({VjYZohi!wvUr|1N&|vU8RKM3X z^ef_jVR)Ua`~HFa7pl`J+feWa@({>hr@W=daPTXXA2t|`xNvm6P2$YgeRrO=E}@FdMWl(UUTfnOpow-r5++LJtRG~}f> z`8o#t8s*>Y84G@ueAb9C@S~c$D?d`uW5}y?91r<3^tn=UPXNCp{&$A=!_Ft@=f(fj z@NwpOt#HV1)!bbKku72 zRk-#m&dJ{$6T=r~_pbk1M+acY0v*O=c&^+z_G3w3tW=ju={5_|{s=iIsI zC&U99{(ZljL8=E(VVyA81?(zLxsI zB?0}<{_;>p6^Vkp^jxpdcHNyGl+WZlHDY; zmE*v>kpEbOUO;nqQuGnzmvIF?Voac^u2eMW03D6 z{wI|G@337m`aJ9MqCNN6FrVX)zoz*|mM`~7@EJz_d$Un|o<9zqnau8^l&p0M@>NZ| zew4bNoWxFpuQl=Cxs6cWcUl7YL>uut_UgLy=%?t1ZTRm$hpG8io6kT#yZ9aiU$6eu z|15ZA@jq+a%fCwm|6%@LIdxq+y5%|Wv*P_CzFu7`^Lg+j@f?XYq{d%|{7YJInG|vZ{2Y1uubY42 zyJ)**+TDWu4y}%ta@GAd_%3ao#7l{Nh<-s^2Pmw(3c3UNeRSV9Gyh%i&DwpRm-2No z`gXdnU+jAi@|(zAlJ0{qr~BM(vmby*QvFJo@?@@PXvD7rh1VM90tOANt<(z&ptIrQ`5)?GJz9 zJ!#!6*zzOfyJ_o$UW(}?dVlJ-e+s5Sz7rj{@f$vY_oKXj@6X_awe?9arC6yi;B9Dq ztZM{%6Ky@vOK}|X4e}kSogSqUUd9?=q4TTT0Q7$3RwWtaJ5Zf^ z+Z1pgt^FQKuHly86}0wyD2FOqf!ER6>7jg_k1pGj9A<=kAdMS#chP;xH!U|ozKYgP z4`o7k8}O>+v!>eW`iWHZ7IYkTUuOsTQuMhl+1i8Wr+yfeh+dHL&s#V`-k;V#*-~fl zK=Sfm(QDEASVqrHetnM%)TvFrd8{jVed@QSH8O#h(cDAXAA>H}>w>3bfqYq7e~Ws8 zUYh)$O<5uTUtXB1$IjNn+U!%))SF4@*A@5B2N-kdDR^(4Vd4oIC=WP*K06Y)1^JDgc;0353 z&fZ6NBe&~X81mm~U1C$-BH+JhUBYb=x?FeNQ?DrGO|%|c@3CMq-cHY^J&Zr-zEi46jORE3rJGwRbg8ii-FWZ^uRt7wc+P_&TC%lXJorDuw zd)$>rozZ1~hI^KWyd!P5-7|C>@~B=FA%B$Wo6e&jC*LsB2lCITe;PEY1pbNKGfQP% zKY9)QJmtGCr~-NE=fCND|9w>23F>yW|u`bo0ko8j}Wj^M$T zd>tm)ps)+0JN<HfqR?y@yR{%A$k`R|9ua>Zq{c*f5;Cr@!yw- zP}iw86&?tlY~t&aVXFJRM7OZv>z{+v^`$*c20=cnO|s^JPJ_XV2@g^~@1+Gp!1LPh z`a$Zt#GuyyfM>Vi>!sXX-G+jH63-F1j|oNps6F5DQV#zNf&4Xk&b#3Fu)pxT^!&F% z_;AQ4oA~#|_pbw?*8IGVFx+B$E~Cd-^Kl@;aE%4di-i0s)gQc#0M8=UDYQNaAYr zliGY>IqTbfDdacObC%jgqyEAt(flDiZW-iTSec)*gtB+e%fZW7@$2ti!yq>25_%@h zgITG$D4zpU_fJ~1R4>U3KR`31s18cgiK%XQ$bg)>%(6==F%*V}JGuc+l4 zuwQ*QK;G=mtO4tlgzhQw&a4T`5+4hB58=gBALq6S+*b28Ebq;KdllA(9a|g+`FD(e z-YTpstK_)_{4wMEy&(%`1IGN@168kAcPr$-GkzTpWF6R3t8Kb&(PRfWQ~32gSZ$A< zhp1jQ7V~ZjpT~x@Nf z2KpAw(+oEnAA8_et=H3LobK znJb%qPv4c`(~zH_@OfIG+Rrfs5_H{ZI{Gk$kHdk?lWlEu2J+J1pF0aaMd8;M-kyYc ziQtpTd$l_EKl#<&m>;Lr$;Pt!T!1>$sQ#{R=#iS6*vumrA-`C2g&oV4r0WCNW$-!5 zeet~?p35B0tR`9)e@VL6|oFQ7V+{jWiOE_t~U$>6gzcVH{$>U+(W*C9Vm%eyka zVK;Pr?=AGvT0SQWTzV7o<2BF5BEF(WPuRKcFW2(vhWds(;7c`E*p6=c z-pcDP=HCns#UFsr)7->{&(U|UkVlZ8q2(ReoSXVyYRO~W zd`6esz1;Q*WdYXDbp6XIba`G>^nC&OsZ__w=_R;4 zU+VNlUr6nIQuGz%rFYo*8hkpn|JOfnbiLONblD!8#cv@mkN>O(-|6}|kN4n_$_wp0 z&HDg-A^GuDA0R)EytvgzUBCT24Sckg&&izYf711*4xhopwR|>~vyZ-?Y5G++Uv3Bb zG_8&+yEfz-r8*I#e(L)7dw=7zsQ%KGzo3qE*Mh&n$7^}@ zy8ley^E9?7%%8)W*Wu+jcG%tkF2@0j2nH_q`$QiraJhf~&ZkiSczwq@6Uk5QvxfT8 zdxRRnWj|lOtM7N2jc$JIadf$VUwYd@Ui$Q>`d)Os9pocuf2}Uz0RHDVX~OEfL!Y9J zdtq#AOGn86arOVlGdqEA5cyyh$>z>?27f0!oXujr+Pmm@BwM~0-NBO2ALglbhK9L9 zzPjd%*)Bt7@Hk68?^?y;SnFfxFD?1JZWa4~NW1H>DxR=!;BX(xz`!^L1_lNO1_lNO z2DV}Vimli!2DaGUq8OOi9oXI2-QC@o@6PP(&i6cy&*SfU|M8E{_4Pi>o{ibrnK|3& zV9qpj~ zGrC@S$d`63ChGeX&j{|u`=zI8+p};Ex*hMwW?sIrJLKOwu=YIj`x!HVKQujLac1y$ zrcboX0`9`c8L~PB_p9A$bXLe`6Zu`VysC%Nt0u|@9xAqfKRrE;9%6d^{MjKd{o^b2 zMrOWK@0^g&XKs&cF;AuUjk0yto}Wg$D1AsS$QR-L0L(*fE~PK z1z#onER{WizLk3}rHHcN38sga_5=TD`uzpyuN-4} zo$E9yvOMJ9Ih0deffSUt^Hl~H&g=m zcVg?|l%{8Y^?j);+vM3g#w_3b5_(xDHgB5Mk7-yH^0}SZ_sG0orT}nHCpOQN`P%;I zxt!R00p_(zRfBv!CwBZWKXRZtcr7Qk-c){Vd%}j-01t6u$1QVb|C-<}P4BrJy}ju# z3e<*tSJTJ6L2qYzzV&q=-_(hnU+i;dr>YAcVtR>-YWM$I5ArQVUfXXC0>L|&UbtyP z@UGn1{;~BjrqADx`Q_ZJY1?V*Te$~o?l-Iv)ZfbAJ2Ouk5Cp!R_cJqZbRro1uoJsp zg=zkEYzX*CC)RJwywz*;E8N31cUadL@;6K$P%ISulN0NoVR`p2=wD4A9isi>klBv? zn_GClJ*$%^V>8G*JG1^7<{hG&gJ*MQ{ST3vmv7YqJg@2Zv$q5<%f}V4ymcsg71O_U zYz_HfKJSC&3m!*r%zc{n{hl4u2J&sW$7t7$<#pPEhw*U^%%7eM1MkMiMZ{~@$uU#g zf$QTXo@@T;6Z&ADXFg+Vd&m#t^%FGjG_ZrxUCVX^pU?l^G?weM_80mpXLg@rp1x%# z$nW9#9GdUQ)*1W||9hE#U)%-!g)_Sj6t>)<^BuZ^e{yE)#4_*c)eSrmf1k+w&P4R& z+;eE2HAN4|XE%M{S@a?Ir!t{(h5r{73W(F6?@kM)Q8xdPBak3%h+%|=X<4o;7j@YR%gp=@^k7BzJ}Lf zzOp^~cJ2xEfvV??fc$RL4{jI${*Zs}bJA8>=`j%eIsaVdv(KP^=lhL$x0!<=|I73Z z)*;|-eBAO)Enn{{dKx|+lKJjZLm{t^JH1Iisp${&EPNawbDx95ATRy$@ZsPUc|Kmt zhx8>(M*#ag6aqmeSovL%x&gHw%pc*Vijx z`TB8V!ME}Hd&$`{Cha)zvwXbYUP@|dehdAU>Gt!-L;k+-tdj+Y?EREa@Mk zZ{;5Lzj&>Rlc3Ig{{2>?CYCpr$>33ZTy%cT!^TYkAI#?wJhE?Vajr2Hd?25XbIiVl zrRO^I{)yP{)5U4)hNbQ{4e~*J{iRjf`po0MOb5@x*HdEacjenY13Wpe&*nqL_nis; z#)Yka!p3`)xQTw7pVw@>NB_{-knhT`Gu^du9T_r5fj8jeD44%{h@Op)f9S4_-|(z3 z2lB~G|K&1Q=@&es!7uXbC#&N(7JaF3vfQaQ5Au_Qf2D0H=YvOZXYaeePF(4o z`DOS^%zxruTJ!Azu~0ve6YJlorg_10E5M68vG<Mz&^~12|+%a?3Kt3toKQWqr`iY+3N$=0OL^JlRg?uq5_P#7et6yyJI`9&_ zy~lj5(+2QdynVP{^9QYL{i9Zg`Hs9BAzzxetC=@`fnJ%n^Ve(p;Yh7bke43aX)}0L zC-z*)@;!@e0WZ#*(N@Tp;?FP4_g~!x-q49X2QhCEwH>^N@H%zbp?GK_i0+4lCuv& zey;F`l(N|&@TI2LyL%Ws!mRUt;SunzrZ;ss3f_$$C+u^7A3`7K#GXT$hu1g``8X%m zo@DKCtLq8y9sKxYKD-jm?iUXpZPve8 z@-+BC)2q%yKVkZq%x59L#N7V8chDyZzeD!R&Ov^<>BTJP!RMQG9z3}KeujIbwjXL& zzX*QH^fyT^fgk4E$?}s=p>HzXrT!JjuQu!S+@|)})K?*YfPasye(JvHhfLpG;u_@7 zn4WLnb#PfHe#i~*-DaL{qwhEU+LD`)Kjy^x&Di#2@VpIvo*$pgcRofxVfxfHcOZY* z^rMOIg6}omZ|Xhp%fg@0-Fo-I@A316J%jb&I2LERI*{C<%oAdcEUqt=aRQjjdyNA4m{Bx22N+~nG z1HZ-VhilJ40|%jB<^5^QKRJGY{55_YR@0sfW*$MmVS2GqA0dB>*U7Kt7d=P6!?!1k z_8d|B%xB2I;P)e1#b+33f3o_Rpn0<{DW`f+sX1RVtztizwgA^-yv%G3%nEGZ*kf< z&n}12oAK*aoHoug*~s6JZ^*B2%tyM~d$Im2{dg$E+fi}$M<~~6^sK^n(b0(n`Q%Ql zpDIpUm!rsgbc++~*NW4|h4$*{0QsBzeMFo#&a=9&BlsCUo-6sOG-ajfW~^pXqFyYlx+EPuUuBFO9St&VB)M>gb23|@oh zk7@HqKEx&g&m=sYnkG#O{++*zMF*_Rrd+ zhW!8TL<#nJpQM3&84G*vNTH20Jh?or(ie5Hg6H7*EZX?bD<{<+xjsGQfAH~~`L*$x zZ&GFW!%J&E`6Bvzo@e7i3(d#~`S--eC05fs);$yW6YgwWXzC&8w~0MBG2c=yGvsv- z)4YLw7VuA`k4tPx@uRc;gOeD~8AM$IJRqN#k5^>lNEa`&b@rSUsd@j-*&v^vk7JC~ z#^3IIogKWKaDQsDE(dr`;eK?~EvJpM=if;3qjne2Tl4LVByYMKk_++^g}c(wjJcKW zaT9%}$ZO;J{PI9vx^E6I@Ht}tSZL`e^hsj>xKib+`5-@u|6SqQ_{h!Os}}f*7f(!R*EcI8uEiIhUfUC z_8&XTDD#EVmj#ayb=>LJPe1Sx!rdsKMLF=H=I1{5D6jPWPtj$4>QE8#1I^Fv5|1wP z$tL>H{qT%bOE~jyIxXt z$PX8JD|s}xb$y(Gl@4UB0r_DT!+m3Gf)6(9TyIwke2j26ipWzNe3a==#;g6q`Z~&d z)6{jr$BR0y^!uXP7tW}s%qK}u`}SM)AwNA%=I_}hWdMMP9 z{x)MX@a`h-Nt17)%j34tyylRX9&OhGe1Q46w}-X_?<0;w586_-6?hNR8zgG2^s#Nx z`-{9gb*bCd%r~^_Zei~q!?bx8i`S~%ziSxedy2dVWh~ZC={rBGy~(!rkne5QIhLUV zcxQ8a+TBolqUes0?_<^(n!PjlK;iD>@eqBGd0tPb(gpI;3%GOzA878ku4B4^PZZym zcHghlUFi{P(5HyJ3)Sn@1M*(@2yuC#q zs3VWV;B0-t<@Gb+zS`SV?gx2U$IUAOT;A6PO;mg4+5;dz*!;fsCLgHu;1qaZK6_?gk*vQF}uW0bzG;aKn?=I3VIfiCZx$L5cR z{BSe>^EhaNaC%@$$H3-Fs~cEoi`{w%`vrSd$|$vMmyX} z4FfhQy+Df1;Kp-=2c15v_7wHDLf*KqSt()*`Y>_6xRZPMHpol=Pvjq93q>7mey;cBfAD1@|I5DBJIu$Le!Bh@s3Se1OW)Mz0r*nh{t2dE_JJiHf=?FiMjyYT>-`mM9(wa8k038S z=GJ5IA)=0U-(T`X>FL`%1s`vIZsala38oiX^BnSn&HAa{yad<#E7<(^!S!DKgZC44 zJSa=5*O2eY_cMD=9~b^FF7IDn#os_3>8HM+%lnc4;CGN8Ek4(s9{IgjdY#4SGezEw z-nIGwdFkz6d<35@jvrS_wE7eH9Mj7t`mFS{XVIsLyc;#D`xWx~b2>XtzNAb5pCIy9 zI&&3W-%hsvfJ3owkeA*yO6?K5ze8T{uVbIu#WcNL(KfQ_XMs#@3Z4}&L)e}ovaSveR!QP@}lKe z{`6qYp9VQWzOR`N+@)}M;Gx>Fuy^mNkA~Cpe zpR!V|%1M+Sw*q~ZxIVj4p+c^ZU(7$3-H*<{Q+tn%$&~rqX_A8**Wq9G z7TVV+CHMm2E;K8%o6<|%`4?Yk*6G(gHPkV#&lY-gQ0*fk(?EWixt*ca(<*)bDJ%GL z{(gtu*UC;v2OcBl9lOxVx3+Gqr%`=FddM5=Ik?b)5*fkgnd=F(N>IDQ4tK~e5qS%B z&6G*$>*r?%m-XE#i_%B8L0@35hmhMdE99kz4o8nM^X0o_gS_0%@RR6^%zWDM*&#n$ zxGN2NqxOOqb3$I$nOocwTy9UPueNS{FE_X6g8XX!evsYY>zB&|zEpg_7W!zH7u=Y4 z??%mAtKBZAmojhbGemtis<12{|N zH|Eh>>2_2h$V*T41>IN=!Aj!>6;bB%`4v@oCpz^T-B@Q}2$g8%19|C>sucfc52pyK zv+B=0^LHIfK%D`6KSa_Hnwqa9cyI30G*7hI7rYbq7#cxkQi~*U<;^^^}-9l=XxB0KWdxP0g45vUN88fw}w0@{pgz z#|b>Av9!B#1siAMF_?!sR|KEM*TqWE);BobLFG~8b6V};wJSlLt-Ox2Wj@{Ch<==V z8p~oD*0VC?&kB#F*O%2kcV|_|f8^sFa#+^T*>nNmb`ETu2J_{2R31xJ7FL6N5|NLk zF&;I*y&c&2d{(FVQ}iMZZ2SZB8I@{6zK8>xN14MCOFmAuz)Lx>^`@AwnNSzSo5wfX5f~_F7-y*m?pizpZ|I$hQ$5 zLD{#X_Z0Ob=xlUH$cOUdnbj#}*9p9)1Dm(P{N_INHV*82oTu&2fkQh(eiA=ln16KX z3O>{HJZ;gV9N2ls^7i?=K|acX&1;ybdCV*H$vn^eewQAQUn6`7?eOUde!=uNQ*E7{ z*F&^#?MQGho4ne0@$F}M=Nr8te~Q;<9y>Q2{HE!teye@+-ae53!t*^GMo_*g{lK5{ z{lNT`bAKCW{rt>3A4C7>z`T|AEnoABfV`8V;ThHp059ms_H!$TN%XGgK=2Z#Pc1qK z+|%@hr+j-@ z{_<(`clD<@!t+TT z)==unC-HrK)$)t)^#PJ_qu-gs-6%xue1Ja{r~Zm*^RK5z{-@mumfd%ijrI4EYcz)=s-; z(Z=)dM-Mjr<FZEPwnn`W)fA=vUw>$j6wzAoXhS^`={+*MM)~pL@u%i?%gc z3%=d-piJwOerO2#Zjs+lrCl~aUV67S=r{O$kVBRubn_VcbJGWo*$8!>aNnZkpT0!D z%l|IsGtO;>{C(3;6xag(nENsuBc;_G9N%Mf#yTFt1JoC**(KGPRZECqhS-o~c zUV4Mk=vmEteDb}J&*{wCOSQE+ozJ4XJF|8R^JG)@K|Uq-@|Nq=|08;4?rgmg&ua%D zUx9lL%R`!4`XG2E?rF63I*R;6ui(tufoxrl3%d_NzJN1pHzsKFxvEz>V&m+&CSIGL zHDnQbQD@fPX6rwAv^r{&XU`om+Wf4qd5(emI{^!7d@cDzswBIK> z$Uoxi3B+mZExgHm1N@xuuhe)Lx|>=5dxe{j_i|?aKkV;~w%h_Q?5zK;P+Cjsaks(K z@cb%mJ3pPe3;vz2W3bBJ+0w%M9{66q{j0QfBy4?+@YfVS4fE3JovpKeC$>(-q7C<< z{$o++C-v|906abad)aobyM$g?cv_2R=p)Fd;ol3}{-c>5gMSf!*H3D98~u>E{TIqV zf&4nY|Jim9@q7l}#fi;(TBXfHd^rZax$sLgzS?uhHx_lY_4?MJ4-m zI9sm2LI2Iykzjw2f_(eg{LPzZ zKZCmp-$etbd;$M0=1cCP%=Ny4e-iT|chQ(F=y!x~qZ!u{ATRwyv2WlT`8um?{$|H7 z=u5=!Ys{w#d%i<{s_-c4lko@mK+|*B{Q|Gce|Lk;)12H2y^Qc7phc0{w<%U$>w8LzJmUod$8t@XQhDrb1_e|CDr?mE`PtHX%;ugOaFZb{X3u6 z8K%t}Em|fONf> zMxP?wg(_6Y40-8&7NF1OdGfNUZg86 zj>`qxGs(i9qglRU|J+JncME-@nXf%R59FoiYLi##M{{~{*Z;0DqK+H2dyGC*xRovj z|#ub3imGiJB)GG+Ck7Hr;NxMuhWaGv>K3nfA#({Xy^)Gr}-+np& zB4T@C*dFQi1{VSEC+cY9`ERPd=zbr__cA|sL5^bo$;&;$yqy1aZ)tI;(_Pf}q@esI zx$FID^6xz~8QmE7ZlyMLeIYO1(XAA?oX0URsx*AAF;3n}nM2AbJ#ZVkoJW~=P+7=J z@A%sf+!z<_MoIUTQ+l85<-z5ArF0L`Cz<1dBCAw@y!6Q(D}u{;o!$6hDU+Bj8D{UU%;p&i= zzH>wka5=y7hf^)^&SHOhQo9anzZj2h%q#JtL|(O_j`S5T(A$Xm-sI7Vda3^Z93?H#hxsqd>^(9cCMsII!=brh^AuqjIvlig`e7A6I{Na_yEx|jP+u3({D{#4;4-&TqH~wBP ziae?I!!_DMzLmNCh1RP*I#rl5f36$4v46a&N1=9*Z)w(lzP&woGt!5Tm-;UrR zBJWGH=Akzd?nf1eb%uO>?rc4cb&t^N2=}Kt%ep{b`rEc$!5i>-J$zk`*WJK_OrKZ1 zJ9ty!zSPUDhtktr_!n<0>UdMb`n{k|h*>{>#@LBpO z!hI>>liJ$^4~D#~A|&>PSCXITE~ynNNCb z1b7?aKGfH1BzSw%)4fFRD%_jmR*!0)fJchF2c7$<_A)_ZAwO2+-RX9^ao`j9 z_ZY6-uLB0CJ`%qJ=`9eloU7h3T_?bSBVfV?rE$dwM1ngu?YU$5A>`T`67 z#r1h%EPuM$Y^XC`)N!M(S)-KxWceI$eSS0>4?n8iT&3?ij6Pb_cc*FNqam-)FJs$t z$#p)sG5^|wdY?u&+S}TA{z3~NFMa5Hbp3O~wD|%F{bC?5>(B9B1a8a+bf;3^(T(|H zR$A6{G32GY9Z-8<#ifup=GnQ^-eqcU7PbuX#{5t>I(Y)!m?!8)eb>c8ezIuSS!qqu z72w8vNq368p!S^8S3=%c&%sJ<)2s$J+UMGJps(6{lw1ROIbSB#H?@x)x(@QjbFL?S zeyDb@3hR~mSxeE4_PG}oZ@U5V9mW3fp>?k|D!tCyP2fg*+n3&U*=*zbbF?otIgj3u zALn7(dTa9nw?e))cdhr6%BJ21UXou2!rAZe-cWnB65Ao4PUJ)Bi~A06H|~*IKUk|F z=!t~4Bun|7ke7bldDlPqPwfn1_fzdm8nYYnuKYSbP3sRF^%6ai>ABbJfxPr%o%e$O z)@E|C^?1Xm+^2ouPG+5ZTlRxng@;nj^apI5ZRa#?{P3%5e|n7O1sWfMd}jW=?A69y zJ7qfzUY2{j=F9H?=?R+O@H+zeTKu>wtc@29JbDy7oR8C2}t$;LjY{ zd*_>Uga+M1zs7$TDPHq^Ezd)Kt|ME|pRH$pb>&5+`zN~uK3L>WlC8J+i`Nr=gdU#8 zI@ujb``q=~_KcWx8R{o>)PDIw^ZnGM;T7<24(vTR^C_FqA8I47*n9b1l&I7-$RFk7 zA>*~*gIoC>eJ3AZ&Ai&K>yY2c$7$cxd~22);Hw?j{%79G;THH(2ez)=bInhOp+^hf zNL_Q^hWu0q_WM51X(J_nh91q2w*<}IUGG9(`lxQ`v-!Ammfscsr#oxMTj<1lP$!0e zF7u)d?t`xszL9!gegM8s_%=#A>mm37{&zWR$L+9h=%@I&cjno4K7#xO{=0|F=TvwC z{(|q%1nqk)WqAtzmhXS&Hx8n|a$w`!m{-a54Dzq|_xN1<-bcqi2Y<_*eP7MGz5sv3 z$DQ8P_M4se%YX3u4(vL~@-c5Qf7gMHZ;97_w=sR+*HGseADc-%^aFex^ul9s?;rl5 z-I3Y-E|lJdC-A)P=ebX#PULyf*4cHB`Oi7uAiq}I1I#0+tosk}$y$Edh1~d|7xMNA zzwcK`0(t2JT#|xs=Ix~f3a5{gT)|HW?@lx7Bm+OEy%A%d+lhQqCRch)FZ8S0k+0|1 z%uWG$>FIx2-OYTKm1!aG&f7n%PIIqx;7Pg1YiCo5_qNW~Q)S+{c6!KX=Jn&Xv(-Os z2BnXfl@UBOe{Z-~yN-DVx!X9~56q|RRQu1&nINBmzt>~=!UNR4(vk)89=zSSSNmS7 z9a8&?$gGghX673`{nOWLbu97OAYYK@*K6NbSMTgfuiGOhcmXrN`;yvom-JNTLuab} zSHoP8&u`Yhlqt83v*SNT+s=FC^C&&-0`$DReIBFzz1y1Qg}n4^2hsDG`Gdo~ATPaC zlKkMA_;VAhfAb`|hv`)c7Jz(S)1SUkd!Fvzkk4)A?Mf6UrOKTYxT1Mec- zkB&sEeSWKQknd#XSB@&L^g9pH_4S#jY2$weFRcK1eH}cuzIUTe6~Xm&T_Ux0B*q_C zyF=Ydkk|WRSbj;$%1WjBC!1Z;6SpDyng22a``L-+6-ZMT}nNKw_1YFjy`a_wij# zhh)dwk*qD1UN{n6?*9w^tspPG`l;67`g-YX`(KQ2qx42^|Hbw37;OK1S>G1w$m6PH z;dbEqIzQ}tPmzGG_Zu_c5Z4~^(jTSm0B($@@}$uD9l?$DJiX~x(@siXkfk%YF+R$R z5)W7Vwy>^{*N;!Ou1@xo==wTTY(1!h&LP619i583B3Y{^(0(^Nj>I&UYUgvSt*xK7NVS&)Q`)xN+U^rt#<0o^R?H zW!@optkRdH9|vxX$Lk(-Cwq@kE`^fse>m%-uPV!AL>wf zlF}19PX;%x!`>9$0ezG>PrYf|iz$#d&R=a@-|DI0<3!$z3ih0)^www5jq#Q~lyb`q z$jj{?kZvZpvEIHfJ&jO%rfcZ>I(Y27+Y>bl>PRo<5d|*yb4;Y#D^!{Td1L)LU%Kx& zSLq=WqQQ;vY<~2;<~(p?oS_eSuTr~nkNJ={u1h{t^}_=2aDINV_g~L8Ed)1?Hy_&9 zKL%XCPqFLarz>hdHGh#Zzi8KDaAVywA4-;WiPG2JReJ{irI0s{e_uM9dpWqVe!CwP z9*=GuC%$x{Ml9r|_i|kUuCKcornPThOkW9Z91p&f^*y>VUeTAnZeIm?W89-J&F;5a z>DRuk;m*eKHnKC;&Gw@W``7*_f1G(8ZzjXM73V2eNvab zkT=E$29S5bec;A8Lw|~%s`3CT9k?IzdV7G~Pd8sb0Isi3&hFz4<{kvs+b8_~{tI0n zzrno4-b0X=zNp+0aDCheyKj2FJf`%<0muKrjr**2zOTl-ao^W|hs(zGCe_M1QDpK}`WMtjeX;zQ2ZxZck6qe}PB zf*a$;eW}0SIi=TiIuCA)-|(gPNB+f)^V*M!RJ{mwjO(f&B}sk>Twhm~-5>orsoh%S zGUScl2l1w$%~!z3it|?+*VpSRxbeFo-gLIqHE?5`j1M_RseR+X8<3ZOSEhSv-xG5a z^2WRjf6D843*1<@Hh?ZayA5vKH~;PxYTkjo(M|}U2d;OOKKwMg@%sq1=zD|vkT*P# zyf>j6<7aBoxtvAz$L{%q$X@G$fGId;HfrGL7Eu8*H*^=B@80(t4laz6vt ze}94H*FIHyh8oWyZ?w1lX=l{(*NKG$Q#GMFFp4B2yR>-{AlD8wdacc z1bO3p^q~a>zx;zw7w4%Dm3ynsuL}GMb!LmaHqJkFg3=Go{RVEF?>^MJ$#)yqpHqA& zDh}Ovj_{%GLw-Phj+jU0O&jfhfzLL*-U0L}{CyLE(K1-j|<8tj^t{8KI8!?ce{!%b0Z%24sRd6-BBFhHy8OZ+FryH^3uI`=2Cie=G;m@7oqm$V^H7pEyef&0i-szR8h|*W|M;|HrC$;(e<%>f8Z$8QF&)k2&2lA7} zdnkWuIlLJ7VBxjs^kcPmSza9S{dqo2`+FZ3C<(5=FJsTkqu!wF@5NX?*Lq*b_Y!r2 zXj-aLN-y`dG`Rk`tev*1aTy!epQ8h*hI?6X{W&yD`yOB2MAx5N+4=x&T9M8y0 zC-mN;-&p&-@~wf8*WcrZY1c8AvJI5J;um@cQ74Sjv}_1@qd!Xfz4C00!1d#b)z7~& z=pVcbzwU)=`O_VOA>T`QC|QoF{rbcZ$m{*Ute+}liYDO3^HCt}I;-|+vqB-SA6M)- z<=98HmmAOw^7?z>Fzxzzw{&yxh5SCk+Py=6s{K{-7LboI^K&Y-0-ww8gY0?7afRBK zwQmjiXfvNOUhS3hwo&HyY-kHUQq-wMACiSB{p2O|AtE10=^{EnK0^FmLI1;h3lF8p z+nDblJdEN>b%Z+dI1K)Q-dP-yuJig)h;`Sm={-WM#sh`vL(C)JMY1^L~=Jt#75Z}81x zKX_4-Tj+7ZJ!y(>IOL_5$SDe7x`gx>0SE(kri3 zd*J!e%KV3^W56ex^_zW0A1*wQ?6!@Cygq&)SZfz8>@yBrA8)`s-S6?>`uWJ(MKzmD zPEx8@ z;KqBLP%8fEzxmZIGoX&FU#HA0aQSz={b}oZzfd4e*)tpR#<-3^`r#2}<81shYY(q* znhQRUf4}U$lcs~(x8#jh<{Q31*MIki^&_3`F(2|P#r?vYEN9V|3HPDJlNUgKp>SV{ zXtYr28Pmlmeb-fV{eBv({av9^iy$xk*biH0`-kP*?_Lafz2A)aJ^!WPbH#b#M?)Q# zfzJ@`Pp{gcj}sn16Z0&Gy!10GW5MT%>y;lZ>%KziRSK^JpCR)8v~h;ooAqA}`6#pg z^egDmrrVWT19|Bk=c|3;cl5btotk&oL7gSSeJHNtdZhuaX5vViUtdrLB82BvV+WG!c?b)gwhx`mPe=zY0rTb1j3BFR~z3E21Q#Q`}OIiD* z%2u^M36F=o@%vxC)a=)3@P%T3`jXw=GvF(Qd(*-7Cuc*mPHwek$r3_y&HT zV!wyjDdal%I^o`Qb+_944!WVtzjeF?zC+Z}+Ve-${xi>QW!~0znRVtp$NW}v`)_x= zYt{*}+b!IKwidnzzSFGJ@PpdN)wvJ(O=f+|)d%34%{ohFKUBJZ@FS(K--W(d)X~QC z$2@`jD)V!XIXnelZF>8|YEL}!8RU)Mv-75VHJ&SdsOt;U8`-TC_bD&h+Cl9T3%!K= zDl=c;J^C8cyY_r-=7a1u3-_cdzHh+SnfU>;)E?OME#%jl`RRAwfp0YH`z(B~^rT@Q z!1e8^rj1)Je+Yd&-_O;w^CGVLC&=r^1Ivdb{S3Zc8fcjklosy$1|Z;;<0@}9Ie0eyvVZz?|Y2jn-I`A)adUGNj~(jT_@ z1-{eF=X~-TT)$pb)1H5iEVuJE-nRzX?d8{D=7p2kEB*XQ^qppY?KS4(%zUet7J>+)?cc-~2Gbr7ux7w?I&ItJ>;{5WW zO~LNq#(C{U^)qBr`o4kaYvuXgG7IFl^6MwN&Rji=zEik6^&6QL^1FmvDSZ_WrB6AX zP3eUvWe49c>S*hCy;pnLO*tUH+sqFx>Ip8l=fy0wH~g;laY4DDj;xMS(pc0 zZx68RfPag;N?(6S?R7_ZLEgB(xYO~Z`N8#e4y)fjUhRn|7J$60)B7E|-hN=;Z??2kXg;M+_ue@gB5$CrfsMv-@?kkqBX_4XvYUwpf$_O>NTL*8g7Sjjm7 zUDmJKzbxdXZ@Q)SEam+mzea4Q2ko;j2d=m0+4ugbsoFa~E)V%F=HKhOyn@o#hE)XL zCax#ib>JlWZsBfpWL+i5Z#J)og;P}q-(dRr3u^BfQ>QoEz%gp=cuUECf0r&Z_Cjr*^59S8}8yxji>Gt>vye=nOIKdo<|uN2>}2c0R~0P@lY zWN-8jzQ(NcbR_1bkFF8~b@b-{tAcv!w)?awMi zLVkt3zb6_2uD_pR^*^;$`;1&8AusE=t{e3auD1u+IVOi{wsIdF>MUw zr5DaP4qR`yu=nE^Zm7Ly^mt|d&Tn+1Kgyl54xI>j{kb4mTjgng`AJHj?J^mBhj?G$ zM)7Ub-oM&Z$Q$qFt@LOO`W8{gN+Y{WgS_nA79 zFjMKPuB*NJ+*!)}!E8}VZ~Xv$r}$hqI#YQL@3RFxPF$~C$r>I5dE@%yM#Hb! zx_(}}k@ud(kT>o-+I7HViPB5oM~^kz+cy_2Rp#sGT@D^&UI)gELSJImX;CW{@OB>KoU8H_Df1qtfG_p&R#QE0wId3G&hp-9G&P;qth$tz$eFYsXcEBT!%N zPg^(I+t&Y)mp*qX*7=(+7vH;f+}h-0gnMW!!XAa~k;j#-%Q}Da(rwjsY~ARmveMr? z_Bhs&`G0f${UCdOe}C;N{9Vh<=g|M^xr$$dI{JG_c0AkqMp565rq03q|Lv_`+=lux zA650vKloPjytd67?+e_>aW&TYo6G&Yw9{SqT>U*J+n-H)KT!IKtLVn_f;;{8c?kKx zx$zw5P938#Z#<`1|Iv--N3CDz2iB3-rN4QSkVmkcYsK}}N`Lb{yD@L{|G1K^%k%nv z*2hr)Z*Dv{xzq4_m^b=WwENUQ`4;my++OwxeD2@e=x=u;+x!Z%e+?5*H9uPMJ>;dk9R2`qJQr9gcEm@e52^9#A6)Mr zX6^hfD=@F0r|kKq<-{*Gb&TUA{vEo!FLc@X74q`Fa4k)Ojh_05Mt+z~rYDsQkroq1-RJtZ8$SBdMAb{|Pl`|mg>$S*PP*BvuCD}D8R z7x1NKet4TiO3&e$7<`_{d(xya=nI5Or`kFjSH`Y; z&*~+Eyu5DYy_j6-GmEECda>-3{_a|A0HyE|&^?fw(?+&|ACI;Iqx^OykyS z|B)jNa^f9&FfF<&Q|dG+}Sw%E&0=d>(@!<9#hnwe@l90KE8JbaDCh_ z>wlmN=z2S!T{o^rWrV!+m=Kroi{Q;-Wbp6PPzOtgUio7_8VR17aYj~dHGy( zw~7b2(ZAe%m

`@BxEBJBF^6?3!!RHJ2pe{Sg*f^Vyz|P<6naV0X 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 SB#vnBp^C%q)8S>pB@O^yS`BXi literal 0 HcmV?d00001 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg2 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg2 new file mode 100644 index 0000000..29003b3 --- /dev/null +++ b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg2 @@ -0,0 +1,22 @@ +[chan1] +19 +[chan2] +47 +[chan3] +12 +[chan4] +20 +[chan5] +1 +[chan6] +29 +[chan7] +32 +[chan8] +24 +[ValU] +0 +2 +0 +0 +0 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1 new file mode 100644 index 0000000000000000000000000000000000000000..76263b816a29098afa3f0cd34ac46f221bd713e7 GIT binary patch literal 227208 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 literal 0 HcmV?d00001 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg2 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg2 new file mode 100644 index 0000000..490d4a4 --- /dev/null +++ b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg2 @@ -0,0 +1,22 @@ +[chan1] +19 +[chan2] +45 +[chan3] +2 +[chan4] +20 +[chan5] +1 +[chan6] +31 +[chan7] +32 +[chan8] +17 +[ValU] +0 +2 +0 +0 +0 From 0c17d0f46c334752076682975627b1fcbc250003 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 20:38:48 -0500 Subject: [PATCH 06/13] Adds some qol bugfixes for the new UI Signed-off-by: Cole Gentry --- src/app.rs | 3 ++ src/ui/activity_bar.rs | 120 +++++++++++++++++++++++------------------ src/ui/channels.rs | 12 +++-- 3 files changed, 80 insertions(+), 55 deletions(-) diff --git a/src/app.rs b/src/app.rs index 765808b..173ae5b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -498,6 +498,9 @@ impl UltraLogApp { self.active_tab = Some(self.tabs.len() - 1); self.show_toast_success("File loaded successfully"); + + // Switch to Channels panel so user can select channels + self.active_panel = ActivePanel::Channels; } LoadResult::Error(e) => { self.show_toast_error(&format!("Error: {}", e)); diff --git a/src/ui/activity_bar.rs b/src/ui/activity_bar.rs index b4b7c94..9cd1020 100644 --- a/src/ui/activity_bar.rs +++ b/src/ui/activity_bar.rs @@ -154,71 +154,89 @@ impl UltraLogApp { } } ActivePanel::Tools => { - // Wrench icon + // Line chart icon (for analysis/computed channels) let stroke = egui::Stroke::new(2.0, color); + let chart_width = size * 0.8; + let chart_height = size * 0.6; + let left = center.x - chart_width / 2.0; + let right = center.x + chart_width / 2.0; + let top = center.y - chart_height / 2.0; + let bottom = center.y + chart_height / 2.0; + + // Draw axes + painter.line_segment( + [egui::pos2(left, top), egui::pos2(left, bottom)], + stroke, + ); + painter.line_segment( + [egui::pos2(left, bottom), egui::pos2(right, bottom)], + stroke, + ); - // Handle (diagonal line) - let handle_start = egui::pos2(center.x - half * 0.5, center.y + half * 0.5); - let handle_end = egui::pos2(center.x + half * 0.1, center.y - half * 0.1); - painter.line_segment([handle_start, handle_end], stroke); - - // Wrench head (arc-like shape using lines) - let head_center = egui::pos2(center.x + half * 0.25, center.y - half * 0.25); - let head_radius = half * 0.45; - - // Draw wrench head as a partial circle with opening - let segments = 8; - let start_angle = std::f32::consts::PI * 0.25; - let end_angle = std::f32::consts::PI * 1.75; - let angle_step = (end_angle - start_angle) / segments as f32; - - for i in 0..segments { - let a1 = start_angle + (i as f32) * angle_step; - let a2 = start_angle + ((i + 1) as f32) * angle_step; - let p1 = egui::pos2( - head_center.x + head_radius * a1.cos(), - head_center.y + head_radius * a1.sin(), - ); - let p2 = egui::pos2( - head_center.x + head_radius * a2.cos(), - head_center.y + head_radius * a2.sin(), - ); - painter.line_segment([p1, p2], stroke); + // Draw a line chart curve + let points = [ + egui::pos2(left + chart_width * 0.1, bottom - chart_height * 0.2), + egui::pos2(left + chart_width * 0.3, bottom - chart_height * 0.6), + egui::pos2(left + chart_width * 0.5, bottom - chart_height * 0.4), + egui::pos2(left + chart_width * 0.7, bottom - chart_height * 0.8), + egui::pos2(left + chart_width * 0.9, bottom - chart_height * 0.5), + ]; + + for i in 0..points.len() - 1 { + painter.line_segment([points[i], points[i + 1]], stroke); } } ActivePanel::Settings => { - // Gear icon - let outer_radius = half * 0.85; - let inner_radius = half * 0.45; - let teeth = 8; - let tooth_depth = half * 0.2; + // Gear/cog icon with rectangular teeth + let body_radius = half * 0.55; + let tooth_outer = half * 0.85; + let tooth_width = 0.35; // Width of each tooth in radians + let teeth = 6; + + // Draw the gear body (filled circle) + painter.circle_stroke(center, body_radius, egui::Stroke::new(2.0, color)); - // Draw gear teeth + // Draw rectangular teeth for i in 0..teeth { let angle = (i as f32) * std::f32::consts::TAU / teeth as f32; - let next_angle = ((i as f32) + 0.5) * std::f32::consts::TAU / teeth as f32; - let outer_point = egui::pos2( - center.x + outer_radius * angle.cos(), - center.y + outer_radius * angle.sin(), + // Calculate the four corners of each tooth + let angle_left = angle - tooth_width / 2.0; + let angle_right = angle + tooth_width / 2.0; + + // Inner corners (on the body circle) + let inner_left = egui::pos2( + center.x + body_radius * angle_left.cos(), + center.y + body_radius * angle_left.sin(), ); - let inner_point = egui::pos2( - center.x + (outer_radius - tooth_depth) * next_angle.cos(), - center.y + (outer_radius - tooth_depth) * next_angle.sin(), + let inner_right = egui::pos2( + center.x + body_radius * angle_right.cos(), + center.y + body_radius * angle_right.sin(), ); - painter.line_segment([outer_point, inner_point], egui::Stroke::new(2.0, color)); - } + // Outer corners (extending outward) + let outer_left = egui::pos2( + center.x + tooth_outer * angle_left.cos(), + center.y + tooth_outer * angle_left.sin(), + ); + let outer_right = egui::pos2( + center.x + tooth_outer * angle_right.cos(), + center.y + tooth_outer * angle_right.sin(), + ); - // Draw outer circle - painter.circle_stroke( - center, - outer_radius - tooth_depth / 2.0, - egui::Stroke::new(2.0, color), - ); + // Draw the tooth as a filled polygon + let tooth_points = vec![inner_left, outer_left, outer_right, inner_right]; + painter.add(egui::Shape::convex_polygon( + tooth_points, + color, + egui::Stroke::NONE, + )); + } - // Draw inner circle (hole) - painter.circle_stroke(center, inner_radius, egui::Stroke::new(1.5, color)); + // Draw center hole + let hole_radius = half * 0.2; + painter.circle_filled(center, hole_radius, egui::Color32::from_rgb(30, 30, 30)); + painter.circle_stroke(center, hole_radius, egui::Stroke::new(1.5, color)); } } } diff --git a/src/ui/channels.rs b/src/ui/channels.rs index b0e4a7a..3933b91 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -399,9 +399,12 @@ impl UltraLogApp { .inner_margin(10.0) .show(ui, |ui| { // Use horizontal layout with content on left, close button on right - ui.horizontal(|ui| { - // Main content column - ui.vertical(|ui| { + // Align to top so cards with different heights don't stair-step + ui.with_layout( + egui::Layout::left_to_right(egui::Align::TOP), + |ui| { + // Main content column + ui.vertical(|ui| { ui.horizontal(|ui| { // Show computed channel indicator if card.is_computed { @@ -503,7 +506,8 @@ impl UltraLogApp { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } }); - }); + }, + ); }); ui.add_space(5.0); From e280643edc5d3f23c5eb12269c117b954f919ab2 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 20:38:56 -0500 Subject: [PATCH 07/13] Formats code Signed-off-by: Cole Gentry --- src/ui/activity_bar.rs | 5 +---- src/ui/channels.rs | 11 ++++------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/ui/activity_bar.rs b/src/ui/activity_bar.rs index 9cd1020..96c0b10 100644 --- a/src/ui/activity_bar.rs +++ b/src/ui/activity_bar.rs @@ -164,10 +164,7 @@ impl UltraLogApp { let bottom = center.y + chart_height / 2.0; // Draw axes - painter.line_segment( - [egui::pos2(left, top), egui::pos2(left, bottom)], - stroke, - ); + painter.line_segment([egui::pos2(left, top), egui::pos2(left, bottom)], stroke); painter.line_segment( [egui::pos2(left, bottom), egui::pos2(right, bottom)], stroke, diff --git a/src/ui/channels.rs b/src/ui/channels.rs index 3933b91..53a86d6 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -400,11 +400,9 @@ impl UltraLogApp { .show(ui, |ui| { // Use horizontal layout with content on left, close button on right // Align to top so cards with different heights don't stair-step - ui.with_layout( - egui::Layout::left_to_right(egui::Align::TOP), - |ui| { - // Main content column - ui.vertical(|ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { + // Main content column + ui.vertical(|ui| { ui.horizontal(|ui| { // Show computed channel indicator if card.is_computed { @@ -506,8 +504,7 @@ impl UltraLogApp { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } }); - }, - ); + }); }); ui.add_space(5.0); From 8d58cd3c3dd07bf4b98e9c101e12f99dc8b1ee2a Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 20:56:26 -0500 Subject: [PATCH 08/13] Updates the UI for the analysis tools and the formula editors Signed-off-by: Cole Gentry --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/index.html | 2 +- src/app.rs | 9 + src/computed.rs | 12 + src/ui/analysis_panel.rs | 226 +++++--- src/ui/computed_channels_manager.rs | 830 ++++++++++++++-------------- src/ui/formula_editor.rs | 172 ++++-- 9 files changed, 725 insertions(+), 532 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f67f6a8..d47193a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "ultralog" -version = "1.7.2" +version = "2.0.0" dependencies = [ "anyhow", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 168b82e..dcb23c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "1.7.2" +version = "2.0.0" edition = "2021" description = "A high-performance ECU log viewer written in Rust" authors = ["Cole Gentry"] diff --git a/README.md b/README.md index 8eae3ef..6861a6d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust. ![CI](https://github.com/SomethingNew71/UltraLog/actions/workflows/ci.yml/badge.svg) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) -![Version](https://img.shields.io/badge/version-1.7.2-green.svg) +![Version](https://img.shields.io/badge/version-2.0.0-green.svg) --- diff --git a/docs/index.html b/docs/index.html index 5859f03..adc3195 100644 --- a/docs/index.html +++ b/docs/index.html @@ -711,7 +711,7 @@ Free and open source — no subscriptions, no licenses, just download and go.

- v1.7.2 + v2.0.0 Open Source diff --git a/src/app.rs b/src/app.rs index 173ae5b..aa39882 100644 --- a/src/app.rs +++ b/src/app.rs @@ -122,6 +122,10 @@ pub struct UltraLogApp { pub(crate) file_computed_channels: HashMap>, /// Whether to show the computed channels manager dialog pub(crate) show_computed_channels_manager: bool, + /// Search filter for computed channels library + pub(crate) computed_channels_search: String, + /// Whether to show the computed channels help popup + pub(crate) show_computed_channels_help: bool, /// State for the formula editor dialog pub(crate) formula_editor_state: FormulaEditorState, // === Analysis System === @@ -131,6 +135,8 @@ pub struct UltraLogApp { pub(crate) analysis_results: HashMap>, /// Whether to show the analysis panel pub(crate) show_analysis_panel: bool, + /// Selected category in analysis panel (None = show all) + pub(crate) analysis_selected_category: Option, } impl Default for UltraLogApp { @@ -177,10 +183,13 @@ impl Default for UltraLogApp { computed_library: ComputedChannelLibrary::load(), file_computed_channels: HashMap::new(), show_computed_channels_manager: false, + computed_channels_search: String::new(), + show_computed_channels_help: false, formula_editor_state: FormulaEditorState::default(), analyzer_registry: AnalyzerRegistry::new(), analysis_results: HashMap::new(), show_analysis_panel: false, + analysis_selected_category: None, } } } diff --git a/src/computed.rs b/src/computed.rs index c11c94f..166a661 100644 --- a/src/computed.rs +++ b/src/computed.rs @@ -440,6 +440,18 @@ impl FormulaEditorState { self.is_open = true; } + /// Open the editor with a pre-filled pattern (for Quick Create) + pub fn open_with_pattern(&mut self, name: &str, formula: &str, unit: &str, description: &str) { + self.editing_template_id = None; + self.name = name.to_string(); + self.formula = formula.to_string(); + self.unit = unit.to_string(); + self.description = description.to_string(); + self.validation_error = None; + self.preview_values = None; + self.is_open = true; + } + /// Close the editor pub fn close(&mut self) { self.is_open = false; diff --git a/src/ui/analysis_panel.rs b/src/ui/analysis_panel.rs index a59e09b..6b64efc 100644 --- a/src/ui/analysis_panel.rs +++ b/src/ui/analysis_panel.rs @@ -2,9 +2,9 @@ //! //! Provides a window for users to run analysis algorithms on the active log file, //! including signal processing filters and statistical analyzers. +//! Features category tabs for filtering and clear separation between tools and results. use eframe::egui; -use std::collections::HashMap; use crate::analysis::{AnalysisResult, Analyzer, AnalyzerConfig, LogDataAccess}; use crate::app::UltraLogApp; @@ -41,6 +41,15 @@ enum ParamType { Boolean, } +/// Category labels for the tab bar +const CATEGORIES: &[(&str, &str)] = &[ + ("all", "All"), + ("Filters", "Filters"), + ("Statistics", "Statistics"), + ("AFR Analysis", "AFR"), + ("Derived", "Derived"), +]; + impl UltraLogApp { /// Render the analysis panel window pub fn render_analysis_panel(&mut self, ctx: &egui::Context) { @@ -54,23 +63,9 @@ impl UltraLogApp { .open(&mut open) .resizable(true) .default_width(550.0) - .default_height(500.0) + .default_height(550.0) .order(egui::Order::Foreground) .show(ctx, |ui| { - // Header - ui.heading("Signal Analysis"); - ui.add_space(4.0); - ui.label( - egui::RichText::new( - "Run signal processing and statistical analysis on log data.", - ) - .color(egui::Color32::GRAY), - ); - ui.add_space(8.0); - - ui.separator(); - ui.add_space(4.0); - // Check if we have a file loaded let has_file = self.selected_file.is_some() && !self.files.is_empty(); @@ -91,7 +86,14 @@ impl UltraLogApp { ui.add_space(40.0); }); } else { - // Show available analyzers grouped by category + // Category tabs at the top + self.render_category_tabs(ui); + + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + + // Main content self.render_analyzer_list(ui); } }); @@ -101,6 +103,41 @@ impl UltraLogApp { } } + /// Render the category filter tabs + fn render_category_tabs(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + for (category_id, display_name) in CATEGORIES { + let is_selected = match &self.analysis_selected_category { + None => *category_id == "all", + Some(cat) => cat == *category_id, + }; + + let text = egui::RichText::new(*display_name); + let text = if is_selected { + text.strong() + } else { + text.color(egui::Color32::GRAY) + }; + + let btn = egui::Button::new(text) + .fill(if is_selected { + egui::Color32::from_rgb(60, 70, 60) + } else { + egui::Color32::TRANSPARENT + }) + .corner_radius(4.0); + + if ui.add(btn).clicked() { + if *category_id == "all" { + self.analysis_selected_category = None; + } else { + self.analysis_selected_category = Some(category_id.to_string()); + } + } + } + }); + } + /// Render the list of available analyzers fn render_analyzer_list(&mut self, ui: &mut egui::Ui) { // Get channel names from the currently selected file, with normalization and sorting @@ -150,17 +187,14 @@ impl UltraLogApp { }) .collect(); - // Group by category - let mut categories: HashMap> = HashMap::new(); - for info in &analyzer_infos { - categories - .entry(info.category.clone()) - .or_default() - .push(info); - } - - let mut sorted_categories: Vec<_> = categories.keys().cloned().collect(); - sorted_categories.sort(); + // Filter by selected category + let filtered_infos: Vec<&AnalyzerInfo> = analyzer_infos + .iter() + .filter(|info| match &self.analysis_selected_category { + None => true, // Show all + Some(cat) => info.category == *cat, + }) + .collect(); // Track actions to perform after rendering let mut analyzer_to_run: Option = None; @@ -172,69 +206,100 @@ impl UltraLogApp { egui::ScrollArea::vertical() .id_salt("analysis_panel_scroll") .show(ui, |ui| { - // Show analysis results at the TOP if any exist + // Results section - always visible at top if there are results if let Some(file_idx) = self.selected_file { if let Some(results) = self.analysis_results.get(&file_idx) { if !results.is_empty() { - egui::CollapsingHeader::new( - egui::RichText::new(format!("Results ({})", results.len())) - .strong() - .size(14.0), - ) - .default_open(true) - .show(ui, |ui| { - for (i, result) in results.iter().enumerate() { - if let Some(action) = - Self::render_analysis_result_with_actions(ui, result, i) - { - match action { - ResultAction::AddToChart => result_to_add = Some(i), - ResultAction::Remove => result_to_remove = Some(i), + // Results header with count + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Results ({})", results.len())) + .strong() + .size(15.0), + ); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .small_button("Clear All") + .on_hover_text("Remove all results") + .clicked() + { + // Mark for clearing + result_to_remove = Some(usize::MAX); } - } - } + }, + ); }); ui.add_space(4.0); + + // Result cards + for (i, result) in results.iter().enumerate() { + if let Some(action) = + Self::render_analysis_result_with_actions(ui, result, i) + { + match action { + ResultAction::AddToChart => result_to_add = Some(i), + ResultAction::Remove => result_to_remove = Some(i), + } + } + } + + ui.add_space(8.0); ui.separator(); - ui.add_space(4.0); + ui.add_space(8.0); } } } - // Show available analyzers grouped by category - for category in &sorted_categories { - if let Some(analyzers) = categories.get(category) { - ui.add_space(4.0); + // Analyzers section header + let category_label = match &self.analysis_selected_category { + None => "All Tools".to_string(), + Some(cat) => cat.clone(), + }; - egui::CollapsingHeader::new( - egui::RichText::new(category).strong().size(14.0), - ) - .default_open(true) - .show(ui, |ui| { - for info in analyzers { - if let Some((id, action)) = Self::render_analyzer_card_with_config( - ui, - info, - &channel_names, - &channel_display_names, - ) { - match action { - AnalyzerAction::Run => { - analyzer_to_run = Some(id); - } - AnalyzerAction::RunAndChart => { - analyzer_to_run_and_chart = Some(id); - } - AnalyzerAction::UpdateConfig(config) => { - config_updates.push((id, config)); - } - } + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!( + "{} ({})", + category_label, + filtered_infos.len() + )) + .strong() + .size(15.0), + ); + }); + + ui.add_space(4.0); + + if filtered_infos.is_empty() { + ui.label( + egui::RichText::new("No analyzers in this category") + .color(egui::Color32::GRAY), + ); + } else { + // Render analyzer cards + for info in filtered_infos { + if let Some((id, action)) = Self::render_analyzer_card_with_config( + ui, + info, + &channel_names, + &channel_display_names, + ) { + match action { + AnalyzerAction::Run => { + analyzer_to_run = Some(id); + } + AnalyzerAction::RunAndChart => { + analyzer_to_run_and_chart = Some(id); + } + AnalyzerAction::UpdateConfig(config) => { + config_updates.push((id, config)); } } - }); - - ui.add_space(4.0); + } } } }); @@ -263,11 +328,14 @@ impl UltraLogApp { self.add_analysis_result_to_chart(idx); } - // Remove result + // Remove result(s) if let Some(idx) = result_to_remove { if let Some(file_idx) = self.selected_file { if let Some(results) = self.analysis_results.get_mut(&file_idx) { - if idx < results.len() { + if idx == usize::MAX { + // Clear all + results.clear(); + } else if idx < results.len() { results.remove(idx); } } @@ -533,7 +601,7 @@ impl UltraLogApp { let mut action: Option = None; egui::Frame::NONE - .fill(egui::Color32::from_rgb(35, 40, 45)) + .fill(egui::Color32::from_rgb(35, 50, 55)) .corner_radius(6) .inner_margin(8.0) .outer_margin(egui::Margin::symmetric(0, 2)) diff --git a/src/ui/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs index 6f5084d..0278068 100644 --- a/src/ui/computed_channels_manager.rs +++ b/src/ui/computed_channels_manager.rs @@ -1,8 +1,7 @@ //! Computed Channels Manager UI. //! -//! Provides a window for users to manage their computed channel library -//! and apply computed channels to the active log file, including quick templates -//! and anomaly detection channels. +//! Provides a simplified window for users to manage their computed channel library +//! and apply computed channels to the active log file. use eframe::egui; @@ -24,19 +23,31 @@ impl UltraLogApp { } let mut open = true; + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); egui::Window::new("Computed Channels") .open(&mut open) .resizable(true) - .default_width(650.0) - .default_height(550.0) + .default_width(500.0) + .default_height(450.0) .order(egui::Order::Foreground) .show(ctx, |ui| { - // Header with Add button + // Header with help button ui.horizontal(|ui| { - ui.heading("Channel Library"); + ui.heading("Computed Channels"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("+ New Channel").clicked() { + // Help button + let help_btn = ui.add( + egui::Button::new(egui::RichText::new("?").size(font_14)) + .min_size(egui::vec2(24.0, 24.0)), + ); + if help_btn.clicked() { + self.show_computed_channels_help = !self.show_computed_channels_help; + } + help_btn.on_hover_text("Show formula syntax help"); + + if ui.button("+ New").clicked() { self.formula_editor_state.open_new(); } }); @@ -44,14 +55,78 @@ impl UltraLogApp { ui.add_space(4.0); ui.label( - egui::RichText::new( - "Create reusable computed channels from mathematical formulas.", - ) - .color(egui::Color32::GRAY), + egui::RichText::new("Create virtual channels from mathematical formulas.") + .size(font_12) + .color(egui::Color32::GRAY), ); + ui.add_space(8.0); + // Quick Create section + ui.label(egui::RichText::new("Quick Create:").size(font_12).strong()); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui + .button("Rate of Change") + .on_hover_text("Create: Channel - Channel[-1]") + .clicked() + { + self.formula_editor_state.open_with_pattern( + "Rate of Change", + "{channel} - {channel}[-1]", + "/sample", + "Rate of change per sample", + ); + } + if ui + .button("Moving Avg") + .on_hover_text("Create: 3-sample moving average") + .clicked() + { + self.formula_editor_state.open_with_pattern( + "Moving Average", + "({channel} + {channel}[-1] + {channel}[-2]) / 3", + "", + "3-sample moving average for smoothing", + ); + } + if ui + .button("% Deviation") + .on_hover_text("Create: Percentage deviation from a target") + .clicked() + { + self.formula_editor_state.open_with_pattern( + "Deviation", + "({channel} - 14.7) / 14.7 * 100", + "%", + "Percentage deviation from target value", + ); + } + }); + + ui.add_space(12.0); ui.separator(); + ui.add_space(8.0); + + // Search filter + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!( + "Your Library ({})", + self.computed_library.templates.len() + )) + .size(font_14) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.computed_channels_search) + .hint_text("🔍 Search...") + .desired_width(120.0), + ); + }); + }); + ui.add_space(4.0); // Library templates section @@ -62,186 +137,52 @@ impl UltraLogApp { egui::RichText::new("No computed channels yet") .color(egui::Color32::GRAY), ); - ui.add_space(8.0); + ui.add_space(4.0); ui.label( - egui::RichText::new( - "Click '+ New Channel' to create your first computed channel.", - ) - .color(egui::Color32::GRAY) - .small(), + egui::RichText::new("Use Quick Create or click '+ New' to get started") + .color(egui::Color32::GRAY) + .size(font_12), ); ui.add_space(20.0); }); } else { - ui.label( - egui::RichText::new(format!( - "Templates ({})", - self.computed_library.templates.len() - )) - .strong(), - ); - ui.add_space(4.0); - - // Collect actions to perform after rendering (avoid borrow issues) + // Collect actions to perform after rendering let mut template_to_edit: Option = None; let mut template_to_delete: Option = None; let mut template_to_apply: Option = None; + let mut template_to_duplicate: Option = None; - // Group templates by category - let categories: Vec = { - let mut cats: Vec = self - .computed_library - .templates - .iter() - .map(|t| { - if t.category.is_empty() { - "Custom".to_string() - } else { - t.category.clone() - } - }) - .collect(); - cats.sort(); - cats.dedup(); - // Put common categories in a specific order - let order = ["Rate", "Engine", "Smoothing", "Anomaly", "Custom"]; - cats.sort_by_key(|c| { - order - .iter() - .position(|&o| o == c) - .unwrap_or(order.len()) - }); - cats - }; + let search_lower = self.computed_channels_search.to_lowercase(); egui::ScrollArea::vertical() .id_salt("library_templates_scroll") - .max_height(300.0) + .max_height(200.0) .show(ui, |ui| { - for category in &categories { - let cat_templates: Vec<&ComputedChannelTemplate> = self - .computed_library - .templates - .iter() - .filter(|t| { - let t_cat = if t.category.is_empty() { - "Custom" - } else { - &t.category - }; - t_cat == category - }) - .collect(); - - if cat_templates.is_empty() { - continue; + for template in &self.computed_library.templates { + // Filter by search + if !search_lower.is_empty() { + let matches = template + .name + .to_lowercase() + .contains(&search_lower) + || template.formula.to_lowercase().contains(&search_lower) + || template.category.to_lowercase().contains(&search_lower); + if !matches { + continue; + } } - // Category header with color - let cat_color = match category.as_str() { - "Rate" => egui::Color32::from_rgb(100, 180, 255), - "Engine" => egui::Color32::from_rgb(255, 180, 100), - "Smoothing" => egui::Color32::from_rgb(180, 255, 100), - "Anomaly" => egui::Color32::from_rgb(255, 100, 100), - _ => egui::Color32::GRAY, - }; - - egui::CollapsingHeader::new( - egui::RichText::new(format!( - "{} ({})", - category, - cat_templates.len() - )) - .color(cat_color), - ) - .default_open(true) - .show(ui, |ui| { - for template in cat_templates { - egui::Frame::NONE - .fill(egui::Color32::from_rgb(50, 50, 50)) - .corner_radius(5.0) - .inner_margin(egui::Margin::symmetric(10, 8)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Template info - ui.vertical(|ui| { - ui.horizontal(|ui| { - // Built-in indicator - if template.is_builtin { - ui.label( - egui::RichText::new("★") - .color(egui::Color32::GOLD), - ); - } - ui.label( - egui::RichText::new(&template.name) - .strong() - .color(egui::Color32::LIGHT_BLUE), - ); - if !template.unit.is_empty() { - ui.label( - egui::RichText::new(format!( - "({})", - template.unit - )) - .small() - .color(egui::Color32::GRAY), - ); - } - }); - ui.label( - egui::RichText::new(&template.formula) - .monospace() - .small() - .color(egui::Color32::from_rgb( - 180, 180, 180, - )), - ); - if !template.description.is_empty() { - ui.label( - egui::RichText::new( - &template.description, - ) - .small() - .color(egui::Color32::GRAY), - ); - } - }); - - // Buttons on the right - ui.with_layout( - egui::Layout::right_to_left( - egui::Align::Center, - ), - |ui| { - if ui.small_button("Delete").clicked() { - template_to_delete = - Some(template.id.clone()); - } - if ui.small_button("Edit").clicked() { - template_to_edit = - Some(template.id.clone()); - } - if self.active_tab.is_some() - && ui - .button( - egui::RichText::new("Apply") - .color( - egui::Color32::WHITE, - ), - ) - .clicked() - { - template_to_apply = - Some((*template).clone()); - } - }, - ); - }); - }); - ui.add_space(4.0); - } - }); + self.render_template_card( + ui, + template, + font_12, + font_14, + &mut template_to_edit, + &mut template_to_delete, + &mut template_to_apply, + &mut template_to_duplicate, + ); + ui.add_space(4.0); } }); @@ -260,275 +201,334 @@ impl UltraLogApp { if let Some(ref template) = template_to_apply { self.apply_computed_channel_template(template); } + + if let Some(ref template) = template_to_duplicate { + let mut new_template = template.clone(); + new_template.id = uuid::Uuid::new_v4().to_string(); + new_template.name = format!("{} (copy)", template.name); + new_template.is_builtin = false; + self.computed_library.add_template(new_template); + let _ = self.computed_library.save(); + self.show_toast_success("Template duplicated"); + } } - ui.add_space(12.0); + ui.add_space(8.0); ui.separator(); - ui.add_space(4.0); + ui.add_space(8.0); // Applied channels section (for current file) - if let Some(tab_idx) = self.active_tab { - let file_idx = self.tabs[tab_idx].file_index; - let applied_count = self - .file_computed_channels - .get(&file_idx) - .map(|c| c.len()) - .unwrap_or(0); - - ui.horizontal(|ui| { + self.render_applied_channels_section(ui, font_12, font_14); + }); + + // Render help popup if open + if self.show_computed_channels_help { + self.render_computed_channels_help(ctx); + } + + if !open { + self.show_computed_channels_manager = false; + } + } + + /// Render a single template card with cleaner layout + #[allow(clippy::too_many_arguments)] + fn render_template_card( + &self, + ui: &mut egui::Ui, + template: &ComputedChannelTemplate, + font_12: f32, + font_14: f32, + template_to_edit: &mut Option, + template_to_delete: &mut Option, + template_to_apply: &mut Option, + template_to_duplicate: &mut Option, + ) { + egui::Frame::NONE + .fill(egui::Color32::from_rgb(45, 48, 45)) + .corner_radius(6.0) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Template info + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::from_rgb(150, 200, 255)) + .size(font_14), + ); + ui.label( + egui::RichText::new(&template.name) + .strong() + .size(font_14) + .color(egui::Color32::WHITE), + ); + if !template.unit.is_empty() { + ui.label( + egui::RichText::new(format!("({})", template.unit)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + } + if template.is_builtin { + ui.label( + egui::RichText::new("★") + .size(font_12) + .color(egui::Color32::GOLD), + ) + .on_hover_text("Built-in template"); + } + }); ui.label( - egui::RichText::new(format!("Applied to Current File ({})", applied_count)) - .strong(), + egui::RichText::new(&template.formula) + .monospace() + .size(font_12) + .color(egui::Color32::from_rgb(160, 180, 160)), ); }); - ui.add_space(4.0); - if applied_count == 0 { - ui.label( - egui::RichText::new( - "No computed channels applied to this file. Click 'Apply' on a template above.", + // Buttons on the right + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Overflow menu for Edit/Delete/Duplicate + ui.menu_button("⋮", |ui| { + if ui.button("Edit").clicked() { + *template_to_edit = Some(template.id.clone()); + ui.close(); + } + if ui.button("Duplicate").clicked() { + *template_to_duplicate = Some(template.clone()); + ui.close(); + } + ui.separator(); + if ui + .button( + egui::RichText::new("Delete") + .color(egui::Color32::from_rgb(255, 120, 120)), + ) + .clicked() + { + *template_to_delete = Some(template.id.clone()); + ui.close(); + } + }); + + // Apply button (primary action) + if self.active_tab.is_some() { + let apply_btn = egui::Button::new( + egui::RichText::new("Apply").color(egui::Color32::WHITE), ) - .color(egui::Color32::GRAY) - .small(), - ); - } else { - let mut channel_to_remove: Option = None; - let mut channel_to_select: Option = None; - - if let Some(channels) = self.file_computed_channels.get(&file_idx) { - egui::ScrollArea::vertical() - .id_salt("applied_channels_scroll") - .max_height(150.0) - .show(ui, |ui| { - for (idx, channel) in channels.iter().enumerate() { - egui::Frame::NONE - .fill(egui::Color32::from_rgb(40, 50, 40)) - .corner_radius(5.0) - .inner_margin(egui::Margin::symmetric(10, 6)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Status indicator - if channel.is_valid() { - ui.label( - egui::RichText::new("●") - .color(egui::Color32::GREEN), - ); - } else { - ui.label( - egui::RichText::new("●") - .color(egui::Color32::RED), - ); - } - - ui.label( - egui::RichText::new(channel.name()) - .color(egui::Color32::LIGHT_GREEN), - ); - - if let Some(error) = &channel.error { - ui.label( - egui::RichText::new(format!( - "Error: {}", - error - )) - .small() - .color(egui::Color32::RED), - ); - } - - ui.with_layout( - egui::Layout::right_to_left( - egui::Align::Center, - ), - |ui| { - if ui.small_button("Remove").clicked() { - channel_to_remove = Some(idx); - } - if channel.is_valid() - && ui.small_button("Add to Chart").clicked() - { - channel_to_select = Some(idx); - } - }, - ); - }); - }); - ui.add_space(2.0); - } - }); - } + .fill(egui::Color32::from_rgb(80, 110, 80)); - if let Some(idx) = channel_to_remove { - self.remove_computed_channel(idx); + if ui.add(apply_btn).clicked() { + *template_to_apply = Some(template.clone()); + } } + }); + }); + }); + } - if let Some(idx) = channel_to_select { - self.add_computed_channel_to_chart(idx); - } + /// Render the applied channels section + fn render_applied_channels_section(&mut self, ui: &mut egui::Ui, font_12: f32, font_14: f32) { + if let Some(tab_idx) = self.active_tab { + let file_idx = self.tabs[tab_idx].file_index; + let applied_count = self + .file_computed_channels + .get(&file_idx) + .map(|c| c.len()) + .unwrap_or(0); + + ui.label( + egui::RichText::new(format!("Applied to Current File ({})", applied_count)) + .size(font_14) + .strong(), + ); + ui.add_space(4.0); + + if applied_count == 0 { + ui.label( + egui::RichText::new("Apply templates from the library above") + .color(egui::Color32::GRAY) + .size(font_12), + ); + } else { + let mut channel_to_remove: Option = None; + let mut channel_to_select: Option = None; + + if let Some(channels) = self.file_computed_channels.get(&file_idx) { + for (idx, channel) in channels.iter().enumerate() { + egui::Frame::NONE + .fill(egui::Color32::from_rgb(40, 45, 50)) + .corner_radius(4.0) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Status indicator + if channel.is_valid() { + ui.label( + egui::RichText::new("●") + .color(egui::Color32::GREEN) + .size(font_12), + ); + } else { + ui.label( + egui::RichText::new("●") + .color(egui::Color32::RED) + .size(font_12), + ); + } + + ui.label( + egui::RichText::new(channel.name()) + .size(font_14) + .color(egui::Color32::LIGHT_GREEN), + ); + + if let Some(error) = &channel.error { + ui.label( + egui::RichText::new(error) + .size(font_12) + .color(egui::Color32::RED), + ); + } + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .small_button("×") + .on_hover_text("Remove") + .clicked() + { + channel_to_remove = Some(idx); + } + if channel.is_valid() + && ui + .small_button("+ Chart") + .on_hover_text("Add to chart") + .clicked() + { + channel_to_select = Some(idx); + } + }, + ); + }); + }); + ui.add_space(2.0); } - } else { - ui.label( - egui::RichText::new("Load a log file to apply computed channels.") - .color(egui::Color32::GRAY), - ); } - // Examples section - ui.add_space(12.0); - ui.separator(); + if let Some(idx) = channel_to_remove { + self.remove_computed_channel(idx); + } - egui::CollapsingHeader::new("Example Formulas") - .default_open(true) - .show(ui, |ui| { - ui.label( - egui::RichText::new("Common computed channel examples:") - .color(egui::Color32::GRAY), - ); - ui.add_space(4.0); + if let Some(idx) = channel_to_select { + self.add_computed_channel_to_chart(idx); + } + } + } else { + ui.label( + egui::RichText::new("Load a log file to apply computed channels") + .color(egui::Color32::GRAY) + .size(font_12), + ); + } + } - // Rate of Change examples - ui.label(egui::RichText::new("Rate of Change:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" RPM - RPM[-1]") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— RPM change per sample") - .small() - .color(egui::Color32::GRAY), - ); - }); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" TPS - TPS@-0.1s") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— TPS change over 100ms") - .small() - .color(egui::Color32::GRAY), - ); - }); + /// Render the help popup with examples and syntax reference + fn render_computed_channels_help(&mut self, ctx: &egui::Context) { + let mut open = true; - ui.add_space(6.0); - ui.label(egui::RichText::new("Engine Calculations:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" (AFR - 14.7) / 14.7 * 100") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— AFR % deviation from stoich") - .small() - .color(egui::Color32::GRAY), - ); - }); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" MAP / 101.325 * 100") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— MAP as % of atmosphere") - .small() - .color(egui::Color32::GRAY), - ); - }); + egui::Window::new("Formula Help") + .open(&mut open) + .resizable(false) + .default_width(400.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + // Channel References + ui.label(egui::RichText::new("Channel References").strong()); + ui.add_space(4.0); + Self::help_row(ui, "RPM", "Current value of RPM channel"); + Self::help_row( + ui, + "\"Manifold Pressure\"", + "Channels with spaces (use quotes)", + ); + Self::help_row(ui, "RPM[-1]", "Previous sample (index offset)"); + Self::help_row(ui, "RPM[+2]", "2 samples ahead"); + Self::help_row(ui, "RPM@-0.1s", "Value 100ms ago (time offset)"); - ui.add_space(6.0); - ui.label(egui::RichText::new("Averaging / Smoothing:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" (RPM + RPM[-1] + RPM[-2]) / 3") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— 3-sample moving average") - .small() - .color(egui::Color32::GRAY), - ); - }); + ui.add_space(12.0); + ui.label(egui::RichText::new("Operators").strong()); + ui.add_space(4.0); + Self::help_row(ui, "+ - * /", "Basic math"); + Self::help_row(ui, "^", "Power (e.g., RPM^2)"); + Self::help_row(ui, "( )", "Grouping"); - ui.add_space(6.0); - ui.label(egui::RichText::new("Unit Conversions:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" MAP * 0.145038") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— kPa to PSI") - .small() - .color(egui::Color32::GRAY), - ); - }); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" (ECT - 32) * 5 / 9") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— Fahrenheit to Celsius") - .small() - .color(egui::Color32::GRAY), - ); - }); - }); + ui.add_space(12.0); + ui.label(egui::RichText::new("Functions").strong()); + ui.add_space(4.0); + Self::help_row(ui, "sin, cos, tan", "Trigonometry"); + Self::help_row(ui, "sqrt, abs", "Square root, absolute value"); + Self::help_row(ui, "ln, log, exp", "Logarithms, exponential"); + Self::help_row(ui, "min, max", "Minimum, maximum"); + Self::help_row(ui, "floor, ceil", "Rounding"); - // Syntax help section - egui::CollapsingHeader::new("Formula Syntax Reference") - .default_open(false) - .show(ui, |ui| { - ui.label(egui::RichText::new("Channel References:").strong()); - ui.label(" RPM - Current value of RPM channel"); - ui.label(" \"Manifold Pressure\" - Channels with spaces (use quotes)"); - ui.label(" RPM[-1] - Previous sample (index offset)"); - ui.label(" RPM[+2] - 2 samples ahead"); - ui.label(" RPM@-0.1s - Value 100ms ago (time offset)"); - - ui.add_space(8.0); - ui.label(egui::RichText::new("Operators:").strong()); - ui.label(" + - * / - Basic math"); - ui.label(" ^ - Power"); - ui.label(" ( ) - Grouping"); - - ui.add_space(8.0); - ui.label(egui::RichText::new("Functions:").strong()); - ui.label(" sin, cos, tan - Trigonometry"); - ui.label(" sqrt, abs - Square root, absolute value"); - ui.label(" ln, log, exp - Logarithms, exponential"); - ui.label(" min, max - Minimum, maximum"); - ui.label(" floor, ceil - Rounding"); - - ui.add_space(8.0); - ui.label(egui::RichText::new("Statistics (for anomaly detection):").strong()); - ui.label(" _mean_RPM - Mean of entire RPM channel"); - ui.label(" _stdev_RPM - Standard deviation of RPM"); - ui.label(" _min_RPM - Minimum value of RPM"); - ui.label(" _max_RPM - Maximum value of RPM"); - ui.label(" _range_RPM - Range (max - min) of RPM"); - ui.label(""); + ui.add_space(12.0); ui.label( - egui::RichText::new("Z-score example: (RPM - _mean_RPM) / _stdev_RPM") - .small() - .color(egui::Color32::LIGHT_GREEN), + egui::RichText::new("Statistics (for anomaly detection)").strong(), ); + ui.add_space(4.0); + Self::help_row(ui, "_mean_RPM", "Mean of entire RPM channel"); + Self::help_row(ui, "_stdev_RPM", "Standard deviation"); + Self::help_row(ui, "_min_RPM / _max_RPM", "Min/max values"); + + ui.add_space(12.0); + ui.label(egui::RichText::new("Examples").strong()); + ui.add_space(4.0); + Self::example_row(ui, "RPM - RPM[-1]", "RPM change per sample"); + Self::example_row(ui, "(AFR - 14.7) / 14.7 * 100", "AFR % deviation"); + Self::example_row(ui, "(RPM - _mean_RPM) / _stdev_RPM", "Z-score"); }); }); if !open { - self.show_computed_channels_manager = false; + self.show_computed_channels_help = false; } } + fn help_row(ui: &mut egui::Ui, code: &str, description: &str) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(code) + .monospace() + .color(egui::Color32::LIGHT_GREEN), + ); + ui.label(egui::RichText::new("—").color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(description).color(egui::Color32::GRAY)); + }); + } + + fn example_row(ui: &mut egui::Ui, formula: &str, description: &str) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(formula) + .monospace() + .color(egui::Color32::from_rgb(180, 200, 255)), + ); + }); + ui.label( + egui::RichText::new(format!(" {}", description)) + .small() + .color(egui::Color32::GRAY), + ); + } + /// Apply a computed channel template to the current file pub fn apply_computed_channel_template(&mut self, template: &ComputedChannelTemplate) { let Some(tab_idx) = self.active_tab else { diff --git a/src/ui/formula_editor.rs b/src/ui/formula_editor.rs index 54f53b2..7cf69b7 100644 --- a/src/ui/formula_editor.rs +++ b/src/ui/formula_editor.rs @@ -1,6 +1,7 @@ //! Formula Editor UI. //! //! Provides a modal window for creating and editing computed channel formulas. +//! Features an expanded channel browser, quick pattern buttons, and rich preview with statistics. use eframe::egui; @@ -29,7 +30,7 @@ impl UltraLogApp { egui::Window::new(title) .open(&mut open) .resizable(true) - .default_width(500.0) + .default_width(550.0) .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .order(egui::Order::Foreground) @@ -63,6 +64,50 @@ impl UltraLogApp { self.validate_current_formula(); } + // Quick pattern buttons + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Insert:").small().weak()); + ui.add_space(4.0); + + // Math operators + for (label, insert) in [ + ("+", " + "), + ("-", " - "), + ("*", " * "), + ("/", " / "), + ("()", "("), + ("abs", "abs("), + ("sqrt", "sqrt("), + ] { + if ui + .small_button(egui::RichText::new(label).monospace()) + .on_hover_text(format!("Insert {}", insert.trim())) + .clicked() + { + self.formula_editor_state.formula.push_str(insert); + self.validate_current_formula(); + } + } + + ui.separator(); + + // Time-shift operators + for (label, insert, tooltip) in [ + ("[-1]", "[-1]", "Previous sample (index offset)"), + ("@-0.1s", "@-0.1s", "Value 0.1 seconds ago"), + ] { + if ui + .small_button(egui::RichText::new(label).monospace()) + .on_hover_text(tooltip) + .clicked() + { + self.formula_editor_state.formula.push_str(insert); + self.validate_current_formula(); + } + } + }); + // Show validation status ui.add_space(4.0); if let Some(error) = &self.formula_editor_state.validation_error { @@ -76,41 +121,46 @@ impl UltraLogApp { ui.add_space(8.0); - // Unit field + // Unit and description in a row ui.horizontal(|ui| { ui.label("Unit:"); ui.add( egui::TextEdit::singleline(&mut self.formula_editor_state.unit) .hint_text("e.g., RPM/s") - .desired_width(150.0), + .desired_width(100.0), ); - }); - ui.add_space(8.0); + ui.add_space(16.0); - // Description field - ui.label("Description (optional):"); - ui.add( - egui::TextEdit::multiline(&mut self.formula_editor_state.description) - .hint_text("What does this computed channel calculate?") - .desired_width(ui.available_width()) - .desired_rows(2), - ); + ui.label("Description:"); + ui.add( + egui::TextEdit::singleline(&mut self.formula_editor_state.description) + .hint_text("Optional description") + .desired_width(ui.available_width() - 20.0), + ); + }); ui.add_space(8.0); - // Channel browser + // Channel browser - expanded by default for discoverability if self.active_tab.is_some() { egui::CollapsingHeader::new("Available Channels") - .default_open(false) + .default_open(true) // Expanded by default .show(ui, |ui| { let channels = self.get_available_channel_names(); if channels.is_empty() { ui.label( - egui::RichText::new("No channels available") - .color(egui::Color32::GRAY), + egui::RichText::new( + "No channels available - load a log file first", + ) + .color(egui::Color32::GRAY), ); } else { + ui.label( + egui::RichText::new("Click to insert into formula:") + .small() + .weak(), + ); egui::ScrollArea::vertical() .id_salt("channel_browser") .max_height(120.0) @@ -136,23 +186,76 @@ impl UltraLogApp { }); } - // Preview section + // Preview section with statistics if let Some(preview_values) = &self.formula_editor_state.preview_values { - ui.add_space(8.0); - ui.separator(); - ui.label(egui::RichText::new("Preview (first 5 values):").strong()); - ui.horizontal(|ui| { - for (i, val) in preview_values.iter().take(5).enumerate() { - if i > 0 { - ui.label(","); - } - ui.label( - egui::RichText::new(format!("{:.2}", val)) - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); + if !preview_values.is_empty() { + ui.add_space(8.0); + ui.separator(); + + // Calculate stats + let valid_values: Vec = preview_values + .iter() + .copied() + .filter(|v| v.is_finite()) + .collect(); + + if !valid_values.is_empty() { + let min = valid_values.iter().copied().fold(f64::INFINITY, f64::min); + let max = valid_values + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let sum: f64 = valid_values.iter().sum(); + let avg = sum / valid_values.len() as f64; + + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Preview").strong()); + ui.add_space(8.0); + + // Stats in a compact row + ui.label(egui::RichText::new("Min:").small().weak()); + ui.label( + egui::RichText::new(format!("{:.2}", min)) + .monospace() + .color(egui::Color32::LIGHT_BLUE), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Avg:").small().weak()); + ui.label( + egui::RichText::new(format!("{:.2}", avg)) + .monospace() + .color(egui::Color32::LIGHT_GREEN), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Max:").small().weak()); + ui.label( + egui::RichText::new(format!("{:.2}", max)) + .monospace() + .color(egui::Color32::from_rgb(255, 180, 100)), + ); + }); + + // Sample values + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Sample:").small().weak()); + for (i, val) in preview_values.iter().take(5).enumerate() { + if i > 0 { + ui.label(egui::RichText::new(",").weak()); + } + ui.label( + egui::RichText::new(format!("{:.2}", val)) + .monospace() + .color(egui::Color32::GRAY), + ); + } + if preview_values.len() > 5 { + ui.label(egui::RichText::new("...").weak()); + } + }); } - }); + } } ui.add_space(16.0); @@ -212,19 +315,20 @@ impl UltraLogApp { Ok(()) => { self.formula_editor_state.validation_error = None; - // Generate preview if we have data + // Generate preview if we have data - get more samples for better stats if let Some(tab_idx) = self.active_tab { let file_idx = self.tabs[tab_idx].file_index; if file_idx < self.files.len() { let file = &self.files[file_idx]; let refs = extract_channel_references(&formula); if let Ok(bindings) = build_channel_bindings(&refs, &available_channels) { + // Get 100 samples for meaningful statistics if let Ok(preview) = generate_preview( &formula, &bindings, &file.log.data, &file.log.times, - 5, + 100, ) { self.formula_editor_state.preview_values = Some(preview); } From 09d1d5badc884425cb7168a7afac4bee07edcd35 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 22:03:34 -0500 Subject: [PATCH 09/13] Tons of small visual bugfixes prepping for v2 Signed-off-by: Cole Gentry --- src/app.rs | 5 +- src/ui/analysis_panel.rs | 7 +- src/ui/channels.rs | 2 +- src/ui/computed_channels_manager.rs | 5 +- src/ui/export.rs | 720 ++++++++++++++++++++++++++++ src/ui/files_panel.rs | 4 +- src/ui/histogram.rs | 125 ++++- src/ui/sidebar.rs | 2 +- src/ui/tab_bar.rs | 2 +- src/ui/tools_panel.rs | 62 ++- 10 files changed, 878 insertions(+), 56 deletions(-) diff --git a/src/app.rs b/src/app.rs index aa39882..b04d57d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1453,8 +1453,9 @@ impl eframe::App for UltraLogApp { self.render_side_panel(ui); }); - // Bottom panel for timeline scrubber (always visible when file loaded) - let show_timeline = self.get_time_range().is_some(); + // Bottom panel for timeline scrubber (visible in LogViewer and Histogram modes) + let show_timeline = + self.get_time_range().is_some() && self.active_tool != ActiveTool::ScatterPlot; if show_timeline { egui::TopBottomPanel::bottom("timeline_panel") diff --git a/src/ui/analysis_panel.rs b/src/ui/analysis_panel.rs index 6b64efc..df69db4 100644 --- a/src/ui/analysis_panel.rs +++ b/src/ui/analysis_panel.rs @@ -42,11 +42,12 @@ enum ParamType { } /// Category labels for the tab bar +/// First element is the category ID (must match analyzer.category()), second is display name const CATEGORIES: &[(&str, &str)] = &[ ("all", "All"), ("Filters", "Filters"), ("Statistics", "Statistics"), - ("AFR Analysis", "AFR"), + ("AFR", "AFR"), ("Derived", "Derived"), ]; @@ -383,11 +384,11 @@ impl UltraLogApp { // Header row with name and Run button ui.horizontal(|ui| { ui.vertical(|ui| { - ui.label(egui::RichText::new(&info.name).strong()); + ui.label(egui::RichText::new(&info.name).strong().size(14.0)); ui.label( egui::RichText::new(&info.description) .color(egui::Color32::GRAY) - .small(), + .size(12.0), ); }); diff --git a/src/ui/channels.rs b/src/ui/channels.rs index 53a86d6..cfc029c 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -489,7 +489,7 @@ impl UltraLogApp { ui.vertical(|ui| { let close_btn = ui.add( egui::Button::new( - egui::RichText::new("✕") + egui::RichText::new("x") .size(font_12) .color(egui::Color32::from_rgb(150, 150, 150)), ) diff --git a/src/ui/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs index 0278068..585501c 100644 --- a/src/ui/computed_channels_manager.rs +++ b/src/ui/computed_channels_manager.rs @@ -156,7 +156,6 @@ impl UltraLogApp { egui::ScrollArea::vertical() .id_salt("library_templates_scroll") - .max_height(200.0) .show(ui, |ui| { for template in &self.computed_library.templates { // Filter by search @@ -291,7 +290,7 @@ impl UltraLogApp { // Buttons on the right ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Overflow menu for Edit/Delete/Duplicate - ui.menu_button("⋮", |ui| { + ui.menu_button("•••", |ui| { if ui.button("Edit").clicked() { *template_to_edit = Some(template.id.clone()); ui.close(); @@ -397,7 +396,7 @@ impl UltraLogApp { egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .small_button("×") + .small_button("x") .on_hover_text("Remove") .clicked() { diff --git a/src/ui/export.rs b/src/ui/export.rs index 9e4e7fd..5cd6971 100644 --- a/src/ui/export.rs +++ b/src/ui/export.rs @@ -355,6 +355,66 @@ impl UltraLogApp { Ok(()) } + /// Export the current histogram view as PNG + pub fn export_histogram_png(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PNG Image", &["png"]) + .set_file_name("ultralog_histogram.png") + .save_file() + else { + return; + }; + + match self.render_histogram_to_png(&path) { + Ok(_) => { + analytics::track_export("histogram_png"); + self.show_toast_success("Histogram exported as PNG"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + + /// Export the current scatter plot view as PNG + pub fn export_scatter_plot_png(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PNG Image", &["png"]) + .set_file_name("ultralog_scatter_plot.png") + .save_file() + else { + return; + }; + + match self.render_scatter_plot_to_png(&path) { + Ok(_) => { + analytics::track_export("scatter_plot_png"); + self.show_toast_success("Scatter plot exported as PNG"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + + /// Export the current scatter plot view as PDF + pub fn export_scatter_plot_pdf(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name("ultralog_scatter_plot.pdf") + .save_file() + else { + return; + }; + + match self.render_scatter_plot_to_pdf(&path) { + Ok(_) => { + analytics::track_export("scatter_plot_pdf"); + self.show_toast_success("Scatter plot exported as PDF"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + /// Export the current histogram view as PDF pub fn export_histogram_pdf(&mut self) { // Show save dialog @@ -819,6 +879,666 @@ impl UltraLogApp { Color::Rgb(Rgb::new(r as f32, g as f32, b as f32, None)) } + + /// Get a PNG color from the heat map gradient based on normalized value (0-1) + fn get_png_heat_color(normalized: f64) -> Rgba { + const HEAT_COLORS: &[[u8; 3]] = &[ + [0, 0, 80], // Dark blue (0.0) + [0, 0, 180], // Blue + [0, 100, 255], // Light blue + [0, 200, 255], // Cyan + [0, 255, 200], // Cyan-green + [0, 255, 100], // Green + [100, 255, 0], // Yellow-green + [200, 255, 0], // Yellow + [255, 200, 0], // Orange + [255, 100, 0], // Red-orange + [255, 0, 0], // Red (1.0) + ]; + + let t = normalized.clamp(0.0, 1.0); + let scaled = t * (HEAT_COLORS.len() - 1) as f64; + let idx = scaled.floor() as usize; + let frac = scaled - idx as f64; + + if idx >= HEAT_COLORS.len() - 1 { + let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; + return Rgba([c[0], c[1], c[2], 255]); + } + + let c1 = HEAT_COLORS[idx]; + let c2 = HEAT_COLORS[idx + 1]; + + let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) as u8; + let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) as u8; + let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) as u8; + + Rgba([r, g, b, 255]) + } + + /// Render histogram to PNG file + fn render_histogram_to_png( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + // Get tab and file data + let tab_idx = self.active_tab.ok_or("No active tab")?; + let config = &self.tabs[tab_idx].histogram_state.config; + let file_idx = self.tabs[tab_idx].file_index; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let mode = config.mode; + let grid_size = config.grid_size.size(); + + let x_idx = config.x_channel.ok_or("X axis not selected")?; + let y_idx = config.y_channel.ok_or("Y axis not selected")?; + let z_idx = if mode == HistogramMode::AverageZ { + config + .z_channel + .ok_or("Z axis not selected for Average mode")? + } else { + 0 // unused + }; + + // Get channel data + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + let z_data = if mode == HistogramMode::AverageZ { + Some(file.log.get_channel_data(z_idx)) + } else { + None + }; + + if x_data.is_empty() || y_data.is_empty() { + return Err("No data available".into()); + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build histogram grid + let mut hit_counts = vec![vec![0u32; grid_size]; grid_size]; + let mut z_sums = vec![vec![0.0f64; grid_size]; grid_size]; + + for i in 0..x_data.len() { + let x_bin = (((x_data[i] - x_min) / x_range) * (grid_size - 1) as f64).round() as usize; + let y_bin = (((y_data[i] - y_min) / y_range) * (grid_size - 1) as f64).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + hit_counts[y_bin][x_bin] += 1; + if let Some(ref z) = z_data { + z_sums[y_bin][x_bin] += z[i]; + } + } + + // Calculate cell values and find min/max for color scaling + let mut cell_values = vec![vec![None::; grid_size]; grid_size]; + let mut min_value: f64 = f64::MAX; + let mut max_value: f64 = f64::MIN; + + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let hits = hit_counts[y_bin][x_bin]; + if hits > 0 { + let value = match mode { + HistogramMode::HitCount => hits as f64, + HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, + }; + cell_values[y_bin][x_bin] = Some(value); + min_value = min_value.min(value); + max_value = max_value.max(value); + } + } + } + + let value_range = if (max_value - min_value).abs() < f64::EPSILON { + 1.0 + } else { + max_value - min_value + }; + + // Image dimensions + let width = 1920u32; + let height = 1080u32; + let margin = 80u32; + let legend_width = 100u32; + + let chart_left = margin; + let chart_right = width - margin - legend_width; + let chart_top = margin; + let chart_bottom = height - margin; + + let chart_width = chart_right - chart_left; + let chart_height = chart_bottom - chart_top; + let cell_width = chart_width as f64 / grid_size as f64; + let cell_height = chart_height as f64 / grid_size as f64; + + // Create image buffer + let mut imgbuf = RgbaImage::new(width, height); + + // Fill with dark background + for pixel in imgbuf.pixels_mut() { + *pixel = Rgba([30, 30, 30, 255]); + } + + // Draw histogram cells + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + if let Some(value) = cell_values[y_bin][x_bin] { + // Calculate color + let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { + (value.ln() / max_value.ln()).clamp(0.0, 1.0) + } else { + ((value - min_value) / value_range).clamp(0.0, 1.0) + }; + let color = Self::get_png_heat_color(normalized); + + // Calculate cell position (Y inverted - higher values at top) + let cell_x = chart_left as f64 + x_bin as f64 * cell_width; + let cell_y = chart_bottom as f64 - (y_bin + 1) as f64 * cell_height; + + // Fill cell + for py in 0..(cell_height.ceil() as u32) { + for px in 0..(cell_width.ceil() as u32) { + let x = (cell_x as u32 + px).min(width - 1); + let y = (cell_y as u32 + py).min(height - 1); + imgbuf.put_pixel(x, y, color); + } + } + } + } + } + + // Draw grid lines + let grid_color = Rgba([80, 80, 80, 255]); + for i in 0..=grid_size { + let x = chart_left + (i as f64 * cell_width) as u32; + let y = chart_top + (i as f64 * cell_height) as u32; + + // Vertical line + for py in chart_top..chart_bottom { + if x < width { + imgbuf.put_pixel(x, py, grid_color); + } + } + + // Horizontal line + for px in chart_left..chart_right { + if y < height { + imgbuf.put_pixel(px, y, grid_color); + } + } + } + + // Draw color scale legend + let legend_left = width - margin - legend_width + 20; + let legend_bar_width = 20u32; + let legend_height = chart_height; + + for i in 0..legend_height { + let t = i as f64 / legend_height as f64; + let color = Self::get_png_heat_color(t); + + for px in 0..legend_bar_width { + let x = legend_left + px; + let y = chart_bottom - i; + if x < width && y < height { + imgbuf.put_pixel(x, y, color); + } + } + } + + // Save the image + imgbuf.save(path)?; + + Ok(()) + } + + /// Render scatter plot to PNG file (exports both left and right plots) + fn render_scatter_plot_to_png( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + let tab_idx = self.active_tab.ok_or("No active tab")?; + + // Image dimensions (wider to fit two plots) + let width = 2560u32; + let height = 1080u32; + let margin = 60u32; + let gap = 40u32; + + let plot_width = (width - 2 * margin - gap) / 2; + let plot_height = height - 2 * margin; + + // Create image buffer + let mut imgbuf = RgbaImage::new(width, height); + + // Fill with dark background + for pixel in imgbuf.pixels_mut() { + *pixel = Rgba([30, 30, 30, 255]); + } + + // Render left plot + let left_config = &self.tabs[tab_idx].scatter_plot_state.left; + let left_rect = (margin, margin, plot_width, plot_height); + self.render_scatter_plot_to_image(&mut imgbuf, left_config, left_rect, tab_idx)?; + + // Render right plot + let right_config = &self.tabs[tab_idx].scatter_plot_state.right; + let right_rect = (margin + plot_width + gap, margin, plot_width, plot_height); + self.render_scatter_plot_to_image(&mut imgbuf, right_config, right_rect, tab_idx)?; + + // Save the image + imgbuf.save(path)?; + + Ok(()) + } + + /// Helper to render a single scatter plot to an image region + fn render_scatter_plot_to_image( + &self, + imgbuf: &mut RgbaImage, + config: &crate::state::ScatterPlotConfig, + rect: (u32, u32, u32, u32), + tab_idx: usize, + ) -> Result<(), Box> { + let (left, top, width, height) = rect; + let right = left + width; + let bottom = top + height; + + let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); + + // Check if we have valid axis selections + let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { + (Some(x), Some(y)) => (x, y), + _ => { + // Draw placeholder + let placeholder_color = Rgba([80, 80, 80, 255]); + for y in top..bottom { + for x in left..right { + imgbuf.put_pixel(x, y, placeholder_color); + } + } + return Ok(()); + } + }; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + + if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { + return Err("No data available".into()); + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build 2D histogram (512 bins like the UI) + const HEATMAP_BINS: usize = 512; + let mut histogram = vec![vec![0u32; HEATMAP_BINS]; HEATMAP_BINS]; + let mut max_hits: u32 = 0; + + for (&x, &y) in x_data.iter().zip(y_data.iter()) { + let x_bin = (((x - x_min) / x_range) * (HEATMAP_BINS - 1) as f64).round() as usize; + let y_bin = (((y - y_min) / y_range) * (HEATMAP_BINS - 1) as f64).round() as usize; + + let x_bin = x_bin.min(HEATMAP_BINS - 1); + let y_bin = y_bin.min(HEATMAP_BINS - 1); + + histogram[y_bin][x_bin] += 1; + max_hits = max_hits.max(histogram[y_bin][x_bin]); + } + + // Fill background with black + let bg_color = Rgba([0, 0, 0, 255]); + for y in top..bottom { + for x in left..right { + imgbuf.put_pixel(x, y, bg_color); + } + } + + let cell_width = width as f64 / HEATMAP_BINS as f64; + let cell_height = height as f64 / HEATMAP_BINS as f64; + + // Draw heatmap cells + for y_bin in 0..HEATMAP_BINS { + for x_bin in 0..HEATMAP_BINS { + let hits = histogram[y_bin][x_bin]; + if hits > 0 { + // Normalize using log scale + let normalized = if max_hits > 1 { + (hits as f64).ln() / (max_hits as f64).ln() + } else { + 1.0 + }; + let color = Self::get_png_heat_color(normalized); + + // Calculate cell position (Y inverted) + let cell_x = left as f64 + x_bin as f64 * cell_width; + let cell_y = bottom as f64 - (y_bin + 1) as f64 * cell_height; + + // Fill cell + for py in 0..(cell_height.ceil() as u32 + 1) { + for px in 0..(cell_width.ceil() as u32 + 1) { + let x = (cell_x as u32 + px).min(right - 1); + let y = (cell_y as u32 + py).min(bottom - 1); + if x >= left && y >= top { + imgbuf.put_pixel(x, y, color); + } + } + } + } + } + } + + Ok(()) + } + + /// Render scatter plot to PDF file + fn render_scatter_plot_to_pdf( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + let tab_idx = self.active_tab.ok_or("No active tab")?; + + // Create PDF document (A4 landscape) + let (doc, page1, layer1) = PdfDocument::new( + "UltraLog Scatter Plot Export", + Mm(297.0), + Mm(210.0), + "Scatter Plot", + ); + + let current_layer = doc.get_page(page1).get_layer(layer1); + + // Draw title + let font = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + let font_regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; + + current_layer.use_text( + "UltraLog Scatter Plot Export", + 16.0, + Mm(20.0), + Mm(200.0), + &font, + ); + + // Subtitle with file info + if let Some(file_idx) = self.selected_file { + if file_idx < self.files.len() { + let file = &self.files[file_idx]; + current_layer.use_text(&file.name, 10.0, Mm(20.0), Mm(192.0), &font_regular); + } + } + + // Layout: two plots side by side + let margin: f64 = 20.0; + let gap: f64 = 15.0; + let plot_width: f64 = (297.0 - 2.0 * margin - gap) / 2.0; + let plot_height: f64 = 150.0; + let plot_top: f64 = 180.0; + let plot_bottom: f64 = plot_top - plot_height; + + // Render left plot + let left_config = &self.tabs[tab_idx].scatter_plot_state.left; + let left_rect = (margin, plot_bottom, plot_width, plot_height); + self.render_scatter_plot_to_pdf_region( + ¤t_layer, + &font_regular, + left_config, + left_rect, + tab_idx, + )?; + + // Render right plot + let right_config = &self.tabs[tab_idx].scatter_plot_state.right; + let right_rect = ( + margin + plot_width + gap, + plot_bottom, + plot_width, + plot_height, + ); + self.render_scatter_plot_to_pdf_region( + ¤t_layer, + &font_regular, + right_config, + right_rect, + tab_idx, + )?; + + // Save PDF + let file = File::create(path)?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer)?; + + Ok(()) + } + + /// Helper to render a single scatter plot to a PDF region + fn render_scatter_plot_to_pdf_region( + &self, + layer: &printpdf::PdfLayerReference, + font: &printpdf::IndirectFontRef, + config: &crate::state::ScatterPlotConfig, + rect: (f64, f64, f64, f64), + tab_idx: usize, + ) -> Result<(), Box> { + let (left, bottom, width, height) = rect; + let right = left + width; + let top = bottom + height; + + let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); + + // Check if we have valid axis selections + let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { + (Some(x), Some(y)) => (x, y), + _ => { + // Draw placeholder text + layer.use_text( + "No axes selected", + 10.0, + Mm((left + width / 2.0 - 15.0) as f32), + Mm((bottom + height / 2.0) as f32), + font, + ); + return Ok(()); + } + }; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + + if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { + return Err("No data available".into()); + } + + // Get channel names for labels + let x_name = file.log.channels[x_idx].name(); + let y_name = file.log.channels[y_idx].name(); + + // Draw axis labels + layer.use_text( + &format!("{} vs {}", y_name, x_name), + 9.0, + Mm(left as f32), + Mm((top + 3.0) as f32), + font, + ); + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build 2D histogram (use smaller bins for PDF) + const PDF_BINS: usize = 64; + let mut histogram = vec![vec![0u32; PDF_BINS]; PDF_BINS]; + let mut max_hits: u32 = 0; + + for (&x, &y) in x_data.iter().zip(y_data.iter()) { + let x_bin = (((x - x_min) / x_range) * (PDF_BINS - 1) as f64).round() as usize; + let y_bin = (((y - y_min) / y_range) * (PDF_BINS - 1) as f64).round() as usize; + + let x_bin = x_bin.min(PDF_BINS - 1); + let y_bin = y_bin.min(PDF_BINS - 1); + + histogram[y_bin][x_bin] += 1; + max_hits = max_hits.max(histogram[y_bin][x_bin]); + } + + let cell_width = width / PDF_BINS as f64; + let cell_height = height / PDF_BINS as f64; + + // Draw heatmap cells + for y_bin in 0..PDF_BINS { + for x_bin in 0..PDF_BINS { + let hits = histogram[y_bin][x_bin]; + if hits > 0 { + // Normalize using log scale + let normalized = if max_hits > 1 { + (hits as f64).ln() / (max_hits as f64).ln() + } else { + 1.0 + }; + let color = Self::get_pdf_heat_color(normalized); + + layer.set_fill_color(color); + + let cell_x = left + x_bin as f64 * cell_width; + let cell_y = bottom + y_bin as f64 * cell_height; + + let rect = printpdf::Polygon { + rings: vec![vec![ + (Point::new(Mm(cell_x as f32), Mm(cell_y as f32)), false), + ( + Point::new(Mm((cell_x + cell_width) as f32), Mm(cell_y as f32)), + false, + ), + ( + Point::new( + Mm((cell_x + cell_width) as f32), + Mm((cell_y + cell_height) as f32), + ), + false, + ), + ( + Point::new(Mm(cell_x as f32), Mm((cell_y + cell_height) as f32)), + false, + ), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(rect); + } + } + } + + // Draw border + let border_color = Color::Rgb(Rgb::new(0.5, 0.5, 0.5, None)); + layer.set_outline_color(border_color); + layer.set_outline_thickness(0.5); + + let border = Line { + points: vec![ + (Point::new(Mm(left as f32), Mm(bottom as f32)), false), + (Point::new(Mm(right as f32), Mm(bottom as f32)), false), + (Point::new(Mm(right as f32), Mm(top as f32)), false), + (Point::new(Mm(left as f32), Mm(top as f32)), false), + ], + is_closed: true, + }; + layer.add_line(border); + + // Draw axis labels + let label_color = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); + layer.set_fill_color(label_color); + + // X axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = x_min + t * x_range; + let x_pos = left + t * width; + layer.use_text( + format!("{:.0}", value), + 6.0, + Mm((x_pos - 3.0) as f32), + Mm((bottom - 5.0) as f32), + font, + ); + } + + // Y axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = y_min + t * y_range; + let y_pos = bottom + t * height; + layer.use_text( + format!("{:.0}", value), + 6.0, + Mm((left - 10.0) as f32), + Mm((y_pos - 1.0) as f32), + font, + ); + } + + Ok(()) + } } /// Draw a line between two points using Bresenham's algorithm diff --git a/src/ui/files_panel.rs b/src/ui/files_panel.rs index 0eeb695..cc9f4b7 100644 --- a/src/ui/files_panel.rs +++ b/src/ui/files_panel.rs @@ -116,7 +116,7 @@ impl UltraLogApp { // Delete button let close_btn = ui.add( egui::Label::new( - egui::RichText::new("×") + egui::RichText::new("x") .size(self.scaled_font(16.0)) .color(egui::Color32::from_rgb(150, 150, 150)), ) @@ -272,7 +272,7 @@ impl UltraLogApp { ui.add_space(12.0); ui.label( - egui::RichText::new("CSV • LOG • TXT • MLG") + egui::RichText::new("CSV • LOG • TXT • MLG • LLG • XRK • DRK") .color(text_gray) .size(self.scaled_font(11.0)), ); diff --git a/src/ui/histogram.rs b/src/ui/histogram.rs index a03a61b..c93ffd0 100644 --- a/src/ui/histogram.rs +++ b/src/ui/histogram.rs @@ -41,6 +41,9 @@ const CELL_HIGHLIGHT_COLOR: egui::Color32 = egui::Color32::WHITE; /// Selected cell highlight color const SELECTED_CELL_COLOR: egui::Color32 = egui::Color32::from_rgb(255, 165, 0); // Orange +/// Crosshair color for cursor tracking during playback +const CURSOR_CROSSHAIR_COLOR: egui::Color32 = egui::Color32::from_rgb(128, 128, 128); // Grey + /// Calculate relative luminance for WCAG contrast ratio /// Uses the sRGB colorspace formula from WCAG 2.1 fn calculate_luminance(color: egui::Color32) -> f64 { @@ -639,6 +642,22 @@ impl UltraLogApp { let pos_x = plot_rect.left() + rel_x * plot_rect.width(); let pos_y = plot_rect.bottom() - rel_y * plot_rect.height(); + // Draw grey crosshairs tracking the cursor position + painter.line_segment( + [ + egui::pos2(pos_x, plot_rect.top()), + egui::pos2(pos_x, plot_rect.bottom()), + ], + egui::Stroke::new(1.0, CURSOR_CROSSHAIR_COLOR), + ); + painter.line_segment( + [ + egui::pos2(plot_rect.left(), pos_y), + egui::pos2(plot_rect.right(), pos_y), + ], + egui::Stroke::new(1.0, CURSOR_CROSSHAIR_COLOR), + ); + // Highlight the cell containing the cursor let cell_x_bin = (rel_x * (grid_size - 1) as f32).round() as usize; let cell_y_bin = (rel_y * (grid_size - 1) as f32).round() as usize; @@ -818,9 +837,14 @@ impl UltraLogApp { } } - // Render legend + // Render legend with selected cell info ui.add_space(8.0); - self.render_histogram_legend(ui, min_value, max_value, x_data.len(), mode, grid_size); + let selected_cell = self.tabs[tab_idx] + .histogram_state + .config + .selected_cell + .as_ref(); + self.render_histogram_legend(ui, min_value, max_value, mode, selected_cell); } /// Get a color from the heat map gradient based on normalized value (0-1) @@ -845,16 +869,16 @@ impl UltraLogApp { egui::Color32::from_rgb(r, g, b) } - /// Render the legend with color scale and stats + /// Render the legend with color scale and cell statistics fn render_histogram_legend( &self, ui: &mut egui::Ui, min_value: f64, max_value: f64, - total_points: usize, mode: HistogramMode, - grid_size: usize, + selected_cell: Option<&SelectedHistogramCell>, ) { + let font_12 = self.scaled_font(12.0); let font_13 = self.scaled_font(13.0); ui.horizontal(|ui| { @@ -915,26 +939,85 @@ impl UltraLogApp { ui.add_space(16.0); - // Stats panel - egui::Frame::NONE - .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) - .corner_radius(4) - .inner_margin(8.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(format!("Grid: {}x{}", grid_size, grid_size)) + // Cell statistics panel (only shown when a cell is selected) + if let Some(cell) = selected_cell { + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .corner_radius(4) + .inner_margin(8.0) + .stroke(egui::Stroke::new(1.0, SELECTED_CELL_COLOR)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Cell identifier + ui.label( + egui::RichText::new(format!( + "Cell [{}, {}]", + cell.x_bin, cell.y_bin + )) .size(font_13) - .color(egui::Color32::WHITE), - ); - ui.add_space(12.0); + .color(SELECTED_CELL_COLOR), + ); + ui.add_space(12.0); + + // Key statistics inline + let stat_color = egui::Color32::from_rgb(180, 180, 180); + let value_color = egui::Color32::WHITE; + + ui.label(egui::RichText::new("Hits:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{}", cell.hit_count)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Mean:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.mean)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Min:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.minimum)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Max:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.maximum)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("σ:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.std_dev)) + .size(font_12) + .color(value_color), + ); + }); + }); + } else { + // Hint when no cell is selected + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .corner_radius(4) + .inner_margin(8.0) + .show(ui, |ui| { ui.label( - egui::RichText::new(format!("Total Points: {}", total_points)) - .size(font_13) - .color(egui::Color32::WHITE), + egui::RichText::new("Click a cell to view statistics") + .size(font_12) + .italics() + .color(egui::Color32::GRAY), ); }); - }); + } }); } diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 82a553a..9453cf1 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -203,7 +203,7 @@ impl UltraLogApp { ui.add_space(12.0); ui.label( - egui::RichText::new("CSV • LOG • TXT • MLG") + egui::RichText::new("CSV • LOG • TXT • MLG • LLG • XRK • DRK") .color(text_gray) .size(self.scaled_font(11.0)), ); diff --git a/src/ui/tab_bar.rs b/src/ui/tab_bar.rs index 98c27a3..95f5bcf 100644 --- a/src/ui/tab_bar.rs +++ b/src/ui/tab_bar.rs @@ -84,7 +84,7 @@ impl UltraLogApp { let font_14 = self.scaled_font(14.0); let close_btn = ui.add( egui::Label::new( - egui::RichText::new("×") + egui::RichText::new("x") .color(egui::Color32::from_rgb(150, 150, 150)) .size(font_14), ) diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs index f01c352..1ecf904 100644 --- a/src/ui/tools_panel.rs +++ b/src/ui/tools_panel.rs @@ -263,20 +263,27 @@ impl UltraLogApp { ); ui.add_space(8.0); - let has_data = self.selected_file.is_some() - && !self.files.is_empty() - && !self.get_selected_channels().is_empty(); - - let can_export_chart = has_data && self.active_tool == ActiveTool::LogViewer; - let can_export_histogram = self.selected_file.is_some() - && !self.files.is_empty() - && self.active_tool == ActiveTool::Histogram; + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + let has_channels = !self.get_selected_channels().is_empty(); + + // Determine what can be exported based on active tool + let can_export_png = match self.active_tool { + ActiveTool::LogViewer => has_file && has_channels, + ActiveTool::ScatterPlot => has_file, + ActiveTool::Histogram => has_file, + }; + + let can_export_pdf = match self.active_tool { + ActiveTool::LogViewer => has_file && has_channels, + ActiveTool::ScatterPlot => has_file, + ActiveTool::Histogram => has_file, + }; ui.horizontal(|ui| { // PNG Export - ui.add_enabled_ui(can_export_chart, |ui| { + ui.add_enabled_ui(can_export_png, |ui| { let btn = egui::Frame::NONE - .fill(if can_export_chart { + .fill(if can_export_png { egui::Color32::from_rgb(71, 108, 155) } else { egui::Color32::from_rgb(50, 50, 50) @@ -286,7 +293,7 @@ impl UltraLogApp { .show(ui, |ui| { ui.label( egui::RichText::new("PNG") - .color(if can_export_chart { + .color(if can_export_png { egui::Color32::WHITE } else { egui::Color32::GRAY @@ -295,18 +302,20 @@ impl UltraLogApp { ); }); - if can_export_chart && btn.response.interact(egui::Sense::click()).clicked() - { - self.export_chart_png(); + if can_export_png && btn.response.interact(egui::Sense::click()).clicked() { + match self.active_tool { + ActiveTool::LogViewer => self.export_chart_png(), + ActiveTool::ScatterPlot => self.export_scatter_plot_png(), + ActiveTool::Histogram => self.export_histogram_png(), + } } - if btn.response.hovered() && can_export_chart { + if btn.response.hovered() && can_export_png { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } }); // PDF Export - let can_export_pdf = can_export_chart || can_export_histogram; ui.add_enabled_ui(can_export_pdf, |ui| { let btn = egui::Frame::NONE .fill(if can_export_pdf { @@ -329,10 +338,10 @@ impl UltraLogApp { }); if can_export_pdf && btn.response.interact(egui::Sense::click()).clicked() { - if can_export_chart { - self.export_chart_pdf(); - } else if can_export_histogram { - self.export_histogram_pdf(); + match self.active_tool { + ActiveTool::LogViewer => self.export_chart_pdf(), + ActiveTool::ScatterPlot => self.export_scatter_plot_pdf(), + ActiveTool::Histogram => self.export_histogram_pdf(), } } @@ -342,10 +351,19 @@ impl UltraLogApp { }); }); - if !has_data { + // Show appropriate help text based on tool and state + if !has_file { + ui.add_space(4.0); + ui.label( + egui::RichText::new("Load a file to enable export") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } else if self.active_tool == ActiveTool::LogViewer && !has_channels { ui.add_space(4.0); ui.label( - egui::RichText::new("Select channels to enable export") + egui::RichText::new("Select channels to enable chart export") .size(font_12) .color(egui::Color32::from_rgb(100, 100, 100)) .italics(), From 52d3a837340c08071f7633507432136aa92a7cae Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 22:23:06 -0500 Subject: [PATCH 10/13] Fixes linux scaling issues in issue #32 Signed-off-by: Cole Gentry --- src/main.rs | 38 +++++++++++++++++++++++++++++++++++++- src/ui/export.rs | 5 ++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index dfdb349..cb5bf10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,34 @@ use eframe::egui::IconData; use std::sync::Arc; use ultralog::app::UltraLogApp; +/// Set up Linux-specific scaling configuration before window creation. +/// This handles common DPI/scaling issues on X11, especially with KDE Plasma. +#[cfg(target_os = "linux")] +fn setup_linux_scaling() { + // If no X11 scale factor is set, try to detect from common environment variables + // This helps on systems where the scale factor isn't properly detected + if std::env::var("WINIT_X11_SCALE_FACTOR").is_err() { + // Check if GDK_SCALE is set (common on GTK-based systems) + if let Ok(gdk_scale) = std::env::var("GDK_SCALE") { + std::env::set_var("WINIT_X11_SCALE_FACTOR", &gdk_scale); + } + // Check QT_SCALE_FACTOR (common on KDE/Qt systems like Kubuntu) + else if let Ok(qt_scale) = std::env::var("QT_SCALE_FACTOR") { + std::env::set_var("WINIT_X11_SCALE_FACTOR", &qt_scale); + } + // Check QT_AUTO_SCREEN_SCALE_FACTOR + else if std::env::var("QT_AUTO_SCREEN_SCALE_FACTOR").is_ok() { + // Let winit auto-detect, but ensure it uses randr for X11 + std::env::set_var("WINIT_X11_SCALE_FACTOR", "randr"); + } + } +} + +#[cfg(not(target_os = "linux"))] +fn setup_linux_scaling() { + // No-op on non-Linux platforms +} + /// Load the platform-specific application icon fn load_app_icon() -> Option> { // Select the appropriate icon based on platform @@ -63,8 +91,9 @@ fn set_macos_app_name() { fn set_macos_app_name() {} fn main() -> eframe::Result<()> { - // Set macOS app name before anything else + // Set up platform-specific configuration before anything else set_macos_app_name(); + setup_linux_scaling(); // Initialize logging tracing_subscriber::fmt::init(); @@ -83,6 +112,13 @@ fn main() -> eframe::Result<()> { .with_app_id("UltraLog") .with_drag_and_drop(true); + // On Linux, start maximized to avoid sizing/scaling issues across different + // desktop environments and display configurations + #[cfg(target_os = "linux")] + { + viewport = viewport.with_maximized(true); + } + // Set icon if loaded successfully if let Some(icon_data) = icon { viewport = viewport.with_icon(icon_data); diff --git a/src/ui/export.rs b/src/ui/export.rs index 5cd6971..1eaa21d 100644 --- a/src/ui/export.rs +++ b/src/ui/export.rs @@ -1041,6 +1041,7 @@ impl UltraLogApp { } // Draw histogram cells + #[allow(clippy::needless_range_loop)] for y_bin in 0..grid_size { for x_bin in 0..grid_size { if let Some(value) = cell_values[y_bin][x_bin] { @@ -1239,6 +1240,7 @@ impl UltraLogApp { let cell_height = height as f64 / HEATMAP_BINS as f64; // Draw heatmap cells + #[allow(clippy::needless_range_loop)] for y_bin in 0..HEATMAP_BINS { for x_bin in 0..HEATMAP_BINS { let hits = histogram[y_bin][x_bin]; @@ -1401,7 +1403,7 @@ impl UltraLogApp { // Draw axis labels layer.use_text( - &format!("{} vs {}", y_name, x_name), + format!("{} vs {}", y_name, x_name), 9.0, Mm(left as f32), Mm((top + 3.0) as f32), @@ -1445,6 +1447,7 @@ impl UltraLogApp { let cell_height = height / PDF_BINS as f64; // Draw heatmap cells + #[allow(clippy::needless_range_loop)] for y_bin in 0..PDF_BINS { for x_bin in 0..PDF_BINS { let hits = histogram[y_bin][x_bin]; From 15247567937b7e53e3f3c29e5a76b6be8d1824b2 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 23:01:07 -0500 Subject: [PATCH 11/13] Adds Emerald ECU support solves issue #34 Signed-off-by: Cole Gentry --- src/app.rs | 15 +- src/bin/test_parser.rs | 13 +- src/parsers/emerald.rs | 723 +++++++++++++++++++++++++++ src/parsers/mod.rs | 2 + src/parsers/types.rs | 12 + src/state.rs | 5 +- src/ui/files_panel.rs | 9 +- tests/common/mod.rs | 6 + tests/parsers/emerald_tests.rs | 878 +++++++++++++++++++++++++++++++++ tests/parsers/mod.rs | 1 + 10 files changed, 1658 insertions(+), 6 deletions(-) create mode 100644 src/parsers/emerald.rs create mode 100644 tests/parsers/emerald_tests.rs diff --git a/src/app.rs b/src/app.rs index b04d57d..cfdee2f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,9 @@ use std::thread; use crate::analysis::{AnalysisResult, AnalyzerRegistry}; use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; -use crate::parsers::{Aim, EcuMaster, EcuType, Haltech, Link, Parseable, RomRaider, Speeduino}; +use crate::parsers::{ + Aim, EcuMaster, EcuType, Emerald, Haltech, Link, Parseable, RomRaider, Speeduino, +}; use crate::state::{ ActivePanel, ActiveTool, CacheKey, FontScale, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, @@ -412,6 +414,17 @@ impl UltraLogApp { e ))), } + } else if Emerald::is_emerald_path(path) + && (Emerald::detect(binary_data) || Emerald::detect_lg2(binary_data)) + { + // Emerald ECU LG1/LG2 format detected + match Emerald::parse_file(path) { + Ok(l) => Ok((l, EcuType::Emerald)), + Err(e) => Err(LoadResult::Error(format!( + "Failed to parse Emerald ECU file: {}", + e + ))), + } } else { // Try parsing as text-based formats // For mmap, we use from_utf8 which doesn't copy the data diff --git a/src/bin/test_parser.rs b/src/bin/test_parser.rs index 3e57ca3..d4e366d 100644 --- a/src/bin/test_parser.rs +++ b/src/bin/test_parser.rs @@ -1,8 +1,9 @@ use std::env; use std::fs; +use std::path::Path; // Import from the library -use ultralog::parsers::{EcuMaster, EcuType, Haltech, Link, Parseable, Speeduino}; +use ultralog::parsers::{EcuMaster, EcuType, Emerald, Haltech, Link, Parseable, Speeduino}; fn main() { // Get file path from command line or use default @@ -38,6 +39,16 @@ fn main() { std::process::exit(1); } } + } else if Emerald::is_emerald_path(Path::new(path)) && Emerald::detect(&binary_data) { + println!("\nDetected: Emerald ECU LG1/LG2 format"); + println!("Parsing Emerald ECU log..."); + match Emerald::parse_file(Path::new(path)) { + Ok(log) => (EcuType::Emerald, log), + Err(e) => { + eprintln!("Parse error: {}", e); + std::process::exit(1); + } + } } else { // Try text-based formats let contents = match std::str::from_utf8(&binary_data) { diff --git a/src/parsers/emerald.rs b/src/parsers/emerald.rs new file mode 100644 index 0000000..68a2fa1 --- /dev/null +++ b/src/parsers/emerald.rs @@ -0,0 +1,723 @@ +//! Emerald ECU (.lg1/.lg2) binary format parser +//! +//! Emerald K6/M3D ECUs use a proprietary binary format for log files: +//! - .lg2 file: Text file containing channel definitions (which parameters are logged) +//! - .lg1 file: Binary file containing timestamped data records +//! +//! Format structure: +//! - LG2 file: INI-like format with [chan1] through [chan8] sections mapping to channel IDs +//! - LG1 file: 24-byte records (8-byte OLE timestamp + 8 x 2-byte u16 values) +//! +//! The channel IDs map to specific ECU parameters (RPM, TPS, temperatures, etc.) + +use serde::Serialize; +use std::error::Error; +use std::path::Path; + +use super::types::{Channel, Log, Meta, Value}; + +/// Known Emerald ECU channel IDs and their metadata +/// These are reverse-engineered from observed data patterns +#[derive(Clone, Debug)] +struct ChannelDefinition { + name: &'static str, + unit: &'static str, + /// Scale factor to apply to raw u16 value + scale: f64, + /// Offset to apply after scaling + offset: f64, +} + +/// Get channel definition for a known channel ID +fn get_channel_definition(id: u8) -> ChannelDefinition { + match id { + // Core engine parameters + 1 => ChannelDefinition { + name: "TPS", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 2 => ChannelDefinition { + name: "Air Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 3 => ChannelDefinition { + name: "MAP", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 4 => ChannelDefinition { + name: "Lambda", + unit: "λ", + scale: 0.001, + offset: 0.0, + }, + 5 => ChannelDefinition { + name: "Fuel Pressure", + unit: "bar", + scale: 0.01, + offset: 0.0, + }, + 6 => ChannelDefinition { + name: "Oil Pressure", + unit: "bar", + scale: 0.01, + offset: 0.0, + }, + 7 => ChannelDefinition { + name: "Oil Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 8 => ChannelDefinition { + name: "Fuel Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 9 => ChannelDefinition { + name: "Exhaust Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 10 => ChannelDefinition { + name: "Boost Target", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 11 => ChannelDefinition { + name: "Boost Duty", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 12 => ChannelDefinition { + name: "Load", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 13 => ChannelDefinition { + name: "Fuel Cut", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 14 => ChannelDefinition { + name: "Spark Cut", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 15 => ChannelDefinition { + name: "Gear", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 16 => ChannelDefinition { + name: "Speed", + unit: "km/h", + scale: 0.1, + offset: 0.0, + }, + 17 => ChannelDefinition { + name: "Battery", + unit: "V", + scale: 0.01, + offset: 0.0, + }, + 18 => ChannelDefinition { + name: "AFR Target", + unit: "AFR", + scale: 0.1, + offset: 0.0, + }, + 19 => ChannelDefinition { + name: "Coolant Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 20 => ChannelDefinition { + name: "RPM", + unit: "RPM", + scale: 1.0, + offset: 0.0, + }, + 21 => ChannelDefinition { + name: "Ignition Advance", + unit: "°", + scale: 0.1, + offset: 0.0, + }, + 22 => ChannelDefinition { + name: "Inj Pulse Width", + unit: "ms", + scale: 0.01, + offset: 0.0, + }, + 23 => ChannelDefinition { + name: "Inj Duty Cycle", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 24 => ChannelDefinition { + name: "Fuel Pressure", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 25 => ChannelDefinition { + name: "Coolant Temp Corr", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 26 => ChannelDefinition { + name: "Air Temp Corr", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 27 => ChannelDefinition { + name: "Acceleration Enrich", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 28 => ChannelDefinition { + name: "Warmup Enrich", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 29 => ChannelDefinition { + name: "Ignition Timing", + unit: "°BTDC", + scale: 0.1, + offset: 0.0, + }, + 30 => ChannelDefinition { + name: "Idle Valve", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 31 => ChannelDefinition { + name: "Inj Duty", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 32 => ChannelDefinition { + name: "MAP", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 33 => ChannelDefinition { + name: "Barometric Pressure", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 34 => ChannelDefinition { + name: "Aux Input 34", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 35 => ChannelDefinition { + name: "Aux Input 35", + unit: "", + scale: 1.0, + offset: 0.0, + }, + // AFR/Lambda channels + 45 => ChannelDefinition { + name: "AFR", + unit: "AFR", + scale: 0.1, + offset: 0.0, + }, + 46 => ChannelDefinition { + name: "AFR", + unit: "AFR", + scale: 0.1, + offset: 0.0, + }, + 47 => ChannelDefinition { + name: "Lambda", + unit: "λ", + scale: 0.01, + offset: 0.0, + }, + // Default for unknown channels + _ => ChannelDefinition { + name: "Unknown", + unit: "", + scale: 1.0, + offset: 0.0, + }, + } +} + +/// Emerald ECU channel metadata +#[derive(Clone, Debug, Serialize)] +pub struct EmeraldChannel { + pub name: String, + pub unit: String, + pub channel_id: u8, + /// Scale factor applied to convert raw u16 to engineering value + #[serde(skip)] + pub scale: f64, + /// Offset applied after scaling + #[serde(skip)] + pub offset: f64, +} + +impl EmeraldChannel { + /// Get the display unit for this channel + pub fn unit(&self) -> &str { + &self.unit + } +} + +/// Emerald ECU log metadata +#[derive(Clone, Debug, Serialize, Default)] +pub struct EmeraldMeta { + /// Source file name (without extension) + pub source_file: String, + /// Number of records in the log + pub record_count: usize, + /// Duration of the log in seconds + pub duration_seconds: f64, + /// Sample rate in Hz (approximate) + pub sample_rate_hz: f64, +} + +/// Emerald ECU log file parser +pub struct Emerald; + +impl Emerald { + /// Check if a file path looks like an Emerald log file (.lg1 or .lg2) + pub fn is_emerald_path(path: &Path) -> bool { + if let Some(ext) = path.extension() { + let ext_lower = ext.to_string_lossy().to_lowercase(); + ext_lower == "lg1" || ext_lower == "lg2" + } else { + false + } + } + + /// Check if a file path is specifically an LG1 file + pub fn is_lg1_path(path: &Path) -> bool { + if let Some(ext) = path.extension() { + ext.to_string_lossy().to_lowercase() == "lg1" + } else { + false + } + } + + /// Check if a file path is specifically an LG2 file + pub fn is_lg2_path(path: &Path) -> bool { + if let Some(ext) = path.extension() { + ext.to_string_lossy().to_lowercase() == "lg2" + } else { + false + } + } + + /// Detect if binary data is Emerald LG1 format + /// LG1 files have 24-byte records with OLE timestamp at the start + pub fn detect(data: &[u8]) -> bool { + // Must have at least one complete record (24 bytes) + if data.len() < 24 { + return false; + } + + // File size must be a multiple of 24 bytes + if !data.len().is_multiple_of(24) { + return false; + } + + // Check if first 8 bytes look like a valid OLE date + // OLE dates are f64 days since 1899-12-30 + // Valid range: ~35000 (1995) to ~55000 (2050) + let timestamp = f64::from_le_bytes([ + data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], + ]); + + // Check for reasonable OLE date range + if !(35000.0..=55000.0).contains(×tamp) { + return false; + } + + // Check that subsequent records also have valid timestamps + if data.len() >= 48 { + let timestamp2 = f64::from_le_bytes([ + data[24], data[25], data[26], data[27], data[28], data[29], data[30], data[31], + ]); + + // Second timestamp should be close to first (within 1 day) + if (timestamp2 - timestamp).abs() > 1.0 { + return false; + } + } + + true + } + + /// Detect if text data is Emerald LG2 format (channel definitions) + /// LG2 files have [chan1] through [chan8] sections + pub fn detect_lg2(data: &[u8]) -> bool { + // Must be valid UTF-8 text + let text = match std::str::from_utf8(data) { + Ok(s) => s, + Err(_) => return false, + }; + + // Must contain [chan1] section marker + if !text.contains("[chan1]") { + return false; + } + + // Should have at least a few channel definitions + let channel_count = (1..=8) + .filter(|i| text.contains(&format!("[chan{}]", i))) + .count(); + + channel_count >= 4 + } + + /// Parse the LG2 channel definition file + fn parse_lg2(contents: &str) -> Result, Box> { + let mut channels: Vec<(u8, u8)> = Vec::new(); + + let lines: Vec<&str> = contents.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i].trim(); + + // Look for [chanN] headers + if line.starts_with("[chan") && line.ends_with(']') { + // Extract channel slot number (1-8) + let slot_str = &line[5..line.len() - 1]; + if let Ok(slot) = slot_str.parse::() { + // Next line should be the channel ID + if i + 1 < lines.len() { + let id_line = lines[i + 1].trim(); + if let Ok(channel_id) = id_line.parse::() { + channels.push((slot, channel_id)); + } + i += 1; + } + } + } + + i += 1; + } + + if channels.is_empty() { + return Err("No channel definitions found in LG2 file".into()); + } + + // Sort by slot number to ensure correct order + channels.sort_by_key(|(slot, _)| *slot); + + Ok(channels) + } + + /// Parse Emerald log files (requires both .lg1 and .lg2) + pub fn parse_file(path: &Path) -> Result> { + // Determine the base path (without extension) + let base_path = path.with_extension(""); + + // Read LG2 file (channel definitions) + let lg2_path = base_path.with_extension("lg2"); + let lg2_contents = std::fs::read_to_string(&lg2_path).map_err(|e| { + format!( + "Cannot read LG2 file '{}': {}. Both .lg1 and .lg2 files are required.", + lg2_path.display(), + e + ) + })?; + + // Parse channel definitions + let channel_defs = Self::parse_lg2(&lg2_contents)?; + + // Read LG1 file (binary data) + let lg1_path = base_path.with_extension("lg1"); + let lg1_data = std::fs::read(&lg1_path).map_err(|e| { + format!( + "Cannot read LG1 file '{}': {}. Both .lg1 and .lg2 files are required.", + lg1_path.display(), + e + ) + })?; + + Self::parse_binary_with_channels(&lg1_data, &channel_defs, path) + } + + /// Parse the LG1 binary data with channel definitions + fn parse_binary_with_channels( + data: &[u8], + channel_defs: &[(u8, u8)], + source_path: &Path, + ) -> Result> { + if !Self::detect(data) { + return Err("Invalid LG1 file - not recognized as Emerald format".into()); + } + + const RECORD_SIZE: usize = 24; + let num_records = data.len() / RECORD_SIZE; + + if num_records == 0 { + return Err("LG1 file contains no data records".into()); + } + + // Build channel metadata + let mut channels: Vec = Vec::with_capacity(8); + for (slot, channel_id) in channel_defs { + let def = get_channel_definition(*channel_id); + let name = if def.name == "Unknown" { + format!("Channel {} (ID {})", slot, channel_id) + } else { + def.name.to_string() + }; + + channels.push(EmeraldChannel { + name, + unit: def.unit.to_string(), + channel_id: *channel_id, + scale: def.scale, + offset: def.offset, + }); + } + + // Parse binary data + let mut times: Vec = Vec::with_capacity(num_records); + let mut data_matrix: Vec> = Vec::with_capacity(num_records); + + let mut first_timestamp: Option = None; + + for i in 0..num_records { + let offset = i * RECORD_SIZE; + + // Read OLE timestamp (8 bytes, f64) + let ole_timestamp = f64::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]); + + // Convert OLE date to seconds since start + let first_ts = *first_timestamp.get_or_insert(ole_timestamp); + let time_seconds = (ole_timestamp - first_ts) * 24.0 * 60.0 * 60.0; + times.push(time_seconds); + + // Read 8 channel values (16 bytes, 8 x u16) + let mut row: Vec = Vec::with_capacity(channels.len()); + for (ch_idx, channel) in channels.iter().enumerate() { + let value_offset = offset + 8 + (ch_idx * 2); + let raw_value = + u16::from_le_bytes([data[value_offset], data[value_offset + 1]]) as f64; + + // Apply scaling and offset + let scaled_value = raw_value * channel.scale + channel.offset; + row.push(Value::Float(scaled_value)); + } + + data_matrix.push(row); + } + + // Calculate metadata + let duration = times.last().copied().unwrap_or(0.0); + let sample_rate = if duration > 0.0 { + num_records as f64 / duration + } else { + 0.0 + }; + + let source_file = source_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + + let meta = EmeraldMeta { + source_file, + record_count: num_records, + duration_seconds: duration, + sample_rate_hz: sample_rate, + }; + + tracing::info!( + "Parsed Emerald ECU log: {} channels, {} records, {:.1}s duration, {:.1} Hz", + channels.len(), + num_records, + duration, + sample_rate + ); + + Ok(Log { + meta: Meta::Emerald(meta), + channels: channels.into_iter().map(Channel::Emerald).collect(), + times, + data: data_matrix, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_valid_lg1() { + // Create minimal valid LG1 data (one record) + let mut data = vec![0u8; 24]; + + // Write a valid OLE timestamp (e.g., 46022.5 = Dec 2025) + let timestamp: f64 = 46022.5; + data[0..8].copy_from_slice(×tamp.to_le_bytes()); + + // Write some channel values + for i in 0..8 { + let value: u16 = (i * 100) as u16; + let offset = 8 + i * 2; + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + assert!(Emerald::detect(&data)); + } + + #[test] + fn test_detect_invalid_data() { + // Empty data + assert!(!Emerald::detect(&[])); + + // Too short + assert!(!Emerald::detect(&[0u8; 23])); + + // Wrong size (not a multiple of 24) + assert!(!Emerald::detect(&[0u8; 25])); + + // Invalid timestamp (too old) + let mut data = vec![0u8; 24]; + let old_timestamp: f64 = 1000.0; // Way too old + data[0..8].copy_from_slice(&old_timestamp.to_le_bytes()); + assert!(!Emerald::detect(&data)); + + // Invalid timestamp (too new) + let mut data = vec![0u8; 24]; + let future_timestamp: f64 = 100000.0; // Way too far in future + data[0..8].copy_from_slice(&future_timestamp.to_le_bytes()); + assert!(!Emerald::detect(&data)); + } + + #[test] + fn test_parse_lg2() { + let lg2_content = "[chan1]\n19\n[chan2]\n46\n[chan3]\n2\n[chan4]\n20\n[chan5]\n1\n[chan6]\n31\n[chan7]\n32\n[chan8]\n17\n[ValU]\n0\n2\n0\n0\n0\n"; + + let channels = Emerald::parse_lg2(lg2_content).unwrap(); + assert_eq!(channels.len(), 8); + assert_eq!(channels[0], (1, 19)); // Coolant Temp + assert_eq!(channels[1], (2, 46)); // AFR + assert_eq!(channels[2], (3, 2)); // Air Temp + assert_eq!(channels[3], (4, 20)); // RPM + assert_eq!(channels[4], (5, 1)); // TPS + assert_eq!(channels[5], (6, 31)); // Inj Duty + assert_eq!(channels[6], (7, 32)); // MAP + assert_eq!(channels[7], (8, 17)); // Battery + } + + #[test] + fn test_channel_definitions() { + // Test known channel IDs + let rpm = get_channel_definition(20); + assert_eq!(rpm.name, "RPM"); + assert_eq!(rpm.unit, "RPM"); + + let coolant = get_channel_definition(19); + assert_eq!(coolant.name, "Coolant Temp"); + assert_eq!(coolant.unit, "°C"); + + let tps = get_channel_definition(1); + assert_eq!(tps.name, "TPS"); + assert_eq!(tps.unit, "%"); + + // Test unknown channel + let unknown = get_channel_definition(255); + assert_eq!(unknown.name, "Unknown"); + } + + #[test] + fn test_is_emerald_path() { + assert!(Emerald::is_emerald_path(Path::new("test.lg1"))); + assert!(Emerald::is_emerald_path(Path::new("test.lg2"))); + assert!(Emerald::is_emerald_path(Path::new("test.LG1"))); + assert!(Emerald::is_emerald_path(Path::new("/path/to/file.lg2"))); + + assert!(!Emerald::is_emerald_path(Path::new("test.csv"))); + assert!(!Emerald::is_emerald_path(Path::new("test.llg"))); + assert!(!Emerald::is_emerald_path(Path::new("test"))); + } + + #[test] + fn test_parse_emerald_example_files() { + // Try to parse the example files + let base_path = Path::new("exampleLogs/emerald/EM Log MG ZS Turbo idle and rev"); + + // Check if files exist + let lg1_path = base_path.with_extension("lg1"); + let lg2_path = base_path.with_extension("lg2"); + + if !lg1_path.exists() || !lg2_path.exists() { + eprintln!( + "Skipping test: example files not found at {}", + base_path.display() + ); + return; + } + + // Parse the files + let log = Emerald::parse_file(&lg1_path).expect("Should parse successfully"); + + // Verify structure + assert_eq!(log.channels.len(), 8, "Should have 8 channels"); + assert!(!log.times.is_empty(), "Should have time data"); + assert!(!log.data.is_empty(), "Should have data records"); + + // Verify channel names + for channel in &log.channels { + let name = channel.name(); + assert!(!name.is_empty(), "Channel name should not be empty"); + eprintln!("Channel: {} ({})", name, channel.unit()); + } + + // Verify metadata + if let Meta::Emerald(meta) = &log.meta { + eprintln!("Source: {}", meta.source_file); + eprintln!("Records: {}", meta.record_count); + eprintln!("Duration: {:.1}s", meta.duration_seconds); + eprintln!("Sample rate: {:.1} Hz", meta.sample_rate_hz); + } + + eprintln!("Parsed {} data records", log.data.len()); + } +} diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs index dda8c72..2c8eef8 100644 --- a/src/parsers/mod.rs +++ b/src/parsers/mod.rs @@ -1,5 +1,6 @@ pub mod aim; pub mod ecumaster; +pub mod emerald; pub mod haltech; pub mod link; pub mod romraider; @@ -8,6 +9,7 @@ pub mod types; pub use aim::Aim; pub use ecumaster::EcuMaster; +pub use emerald::Emerald; pub use haltech::Haltech; pub use link::Link; pub use romraider::RomRaider; diff --git a/src/parsers/types.rs b/src/parsers/types.rs index c65763a..58b1199 100644 --- a/src/parsers/types.rs +++ b/src/parsers/types.rs @@ -3,6 +3,7 @@ use std::error::Error; use super::aim::{AimChannel, AimMeta}; use super::ecumaster::{EcuMasterChannel, EcuMasterMeta}; +use super::emerald::{EmeraldChannel, EmeraldMeta}; use super::haltech::{HaltechChannel, HaltechMeta}; use super::link::{LinkChannel, LinkMeta}; use super::romraider::{RomRaiderChannel, RomRaiderMeta}; @@ -12,6 +13,7 @@ use super::speeduino::{SpeeduinoChannel, SpeeduinoMeta}; #[derive(Clone, Debug, Serialize, Default)] pub enum Meta { Aim(AimMeta), + Emerald(EmeraldMeta), Haltech(HaltechMeta), EcuMaster(EcuMasterMeta), Link(LinkMeta), @@ -36,6 +38,7 @@ pub struct ComputedChannelInfo { #[derive(Clone, Debug)] pub enum Channel { Aim(AimChannel), + Emerald(EmeraldChannel), Haltech(HaltechChannel), EcuMaster(EcuMasterChannel), Link(LinkChannel), @@ -52,6 +55,7 @@ impl Serialize for Channel { { match self { Channel::Aim(a) => a.serialize(serializer), + Channel::Emerald(e) => e.serialize(serializer), Channel::Haltech(h) => h.serialize(serializer), Channel::EcuMaster(e) => e.serialize(serializer), Channel::Link(l) => l.serialize(serializer), @@ -66,6 +70,7 @@ impl Channel { pub fn name(&self) -> String { match self { Channel::Aim(a) => a.name.clone(), + Channel::Emerald(e) => e.name.clone(), Channel::Haltech(h) => h.name.clone(), Channel::EcuMaster(e) => e.name.clone(), Channel::Link(l) => l.name.clone(), @@ -79,6 +84,7 @@ impl Channel { pub fn id(&self) -> String { match self { Channel::Aim(a) => a.name.clone(), + Channel::Emerald(e) => e.channel_id.to_string(), Channel::Haltech(h) => h.id.clone(), Channel::EcuMaster(e) => e.path.clone(), Channel::Link(l) => l.channel_id.to_string(), @@ -91,6 +97,7 @@ impl Channel { pub fn type_name(&self) -> String { match self { Channel::Aim(_) => "AIM".to_string(), + Channel::Emerald(_) => "Emerald".to_string(), Channel::Haltech(h) => h.r#type.as_ref().to_string(), Channel::EcuMaster(e) => e.path.clone(), Channel::Link(_) => "Link".to_string(), @@ -103,6 +110,7 @@ impl Channel { pub fn display_min(&self) -> Option { match self { Channel::Aim(_) => None, + Channel::Emerald(_) => None, Channel::Haltech(h) => h.display_min, Channel::EcuMaster(_) => None, Channel::Link(_) => None, @@ -115,6 +123,7 @@ impl Channel { pub fn display_max(&self) -> Option { match self { Channel::Aim(_) => None, + Channel::Emerald(_) => None, Channel::Haltech(h) => h.display_max, Channel::EcuMaster(_) => None, Channel::Link(_) => None, @@ -127,6 +136,7 @@ impl Channel { pub fn unit(&self) -> &str { match self { Channel::Aim(a) => a.unit(), + Channel::Emerald(e) => e.unit(), Channel::Haltech(h) => h.unit(), Channel::EcuMaster(e) => e.unit(), Channel::Link(l) => l.unit(), @@ -216,6 +226,7 @@ pub enum EcuType { #[default] Haltech, Aim, + Emerald, EcuMaster, MegaSquirt, Aem, @@ -232,6 +243,7 @@ impl EcuType { match self { EcuType::Haltech => "Haltech", EcuType::Aim => "AIM", + EcuType::Emerald => "Emerald", EcuType::EcuMaster => "ECUMaster", EcuType::MegaSquirt => "MegaSquirt", EcuType::Aem => "AEM", diff --git a/src/state.rs b/src/state.rs index e8fa623..f818a2d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -18,8 +18,9 @@ pub const MAX_CHANNELS: usize = 10; pub const MAX_CHART_POINTS: usize = 2000; /// Supported log file extensions (used in file dialogs) -pub const SUPPORTED_EXTENSIONS: &[&str] = - &["csv", "log", "txt", "mlg", "llg", "llg5", "xrk", "drk"]; +pub const SUPPORTED_EXTENSIONS: &[&str] = &[ + "csv", "log", "txt", "mlg", "llg", "llg5", "xrk", "drk", "lg1", "lg2", +]; /// Color palette for chart lines (matches original theme) pub const CHART_COLORS: &[[u8; 3]] = &[ diff --git a/src/ui/files_panel.rs b/src/ui/files_panel.rs index cc9f4b7..5d3a67e 100644 --- a/src/ui/files_panel.rs +++ b/src/ui/files_panel.rs @@ -3,7 +3,7 @@ use eframe::egui; use crate::app::UltraLogApp; -use crate::state::LoadingState; +use crate::state::{LoadingState, SUPPORTED_EXTENSIONS}; use crate::ui::icons::draw_upload_icon; impl UltraLogApp { @@ -271,8 +271,13 @@ impl UltraLogApp { ui.add_space(12.0); + let extensions_text = SUPPORTED_EXTENSIONS + .iter() + .map(|ext| ext.to_uppercase()) + .collect::>() + .join(" • "); ui.label( - egui::RichText::new("CSV • LOG • TXT • MLG • LLG • XRK • DRK") + egui::RichText::new(extensions_text) .color(text_gray) .size(self.scaled_font(11.0)), ); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index fb2de1e..528fba6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -67,6 +67,12 @@ pub mod example_files { // RomRaider example files pub const ROMRAIDER_EUROPEAN: &str = "exampleLogs/romraider/romraiderlog_20251031_170713.csv"; + + // Emerald ECU example files + pub const EMERALD_IDLE_REV: &str = "exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1"; + pub const EMERALD_SHORT_DRIVE: &str = "exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1"; + pub const EMERALD_DIFF_CHANNELS: &str = + "exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1"; } /// Test data generators for synthetic tests diff --git a/tests/parsers/emerald_tests.rs b/tests/parsers/emerald_tests.rs new file mode 100644 index 0000000..3522d9e --- /dev/null +++ b/tests/parsers/emerald_tests.rs @@ -0,0 +1,878 @@ +//! Comprehensive tests for the Emerald ECU LG1/LG2 binary parser +//! +//! Tests cover: +//! - Binary format detection (24-byte records with OLE timestamps) +//! - LG2 channel definition parsing +//! - LG1 binary data parsing +//! - Channel ID mapping +//! - Real file parsing with example logs +//! - Edge cases and error handling + +#[path = "../common/mod.rs"] +mod common; + +use common::assertions::*; +use common::example_files::*; +use common::{example_file_exists, read_example_binary}; +use std::path::Path; +use ultralog::parsers::emerald::Emerald; +use ultralog::parsers::types::Meta; + +// ============================================ +// Format Detection Tests +// ============================================ + +#[test] +fn test_emerald_detection_valid_lg1() { + // Create minimal valid LG1 data (one record = 24 bytes) + // OLE timestamp (f64) + 8 x u16 values + let mut valid_data = Vec::with_capacity(24); + + // Valid OLE timestamp (e.g., 46022.5 = Dec 2025) + let timestamp: f64 = 46022.5; + valid_data.extend_from_slice(×tamp.to_le_bytes()); + + // 8 channel values (16 bytes) + for i in 0..8 { + let value: u16 = (i * 100) as u16; + valid_data.extend_from_slice(&value.to_le_bytes()); + } + + assert_eq!(valid_data.len(), 24); + assert!( + Emerald::detect(&valid_data), + "Should detect valid LG1 data with correct timestamp" + ); +} + +#[test] +fn test_emerald_detection_multiple_records() { + // Create 3 records of valid data + let mut data = Vec::with_capacity(72); + + for record_idx in 0..3 { + let timestamp: f64 = 46022.5 + (record_idx as f64 * 0.001); // ~1 minute apart + data.extend_from_slice(×tamp.to_le_bytes()); + + for i in 0..8 { + let value: u16 = ((record_idx * 8 + i) * 10) as u16; + data.extend_from_slice(&value.to_le_bytes()); + } + } + + assert_eq!(data.len(), 72); + assert!( + Emerald::detect(&data), + "Should detect valid LG1 data with multiple records" + ); +} + +#[test] +fn test_emerald_detection_empty() { + assert!(!Emerald::detect(b""), "Should not detect empty data"); +} + +#[test] +fn test_emerald_detection_too_short() { + let too_short = vec![0u8; 23]; + assert!( + !Emerald::detect(&too_short), + "Should not detect data shorter than 24 bytes" + ); +} + +#[test] +fn test_emerald_detection_wrong_size() { + // Not a multiple of 24 bytes + let wrong_size = vec![0u8; 25]; + assert!( + !Emerald::detect(&wrong_size), + "Should not detect data with size not multiple of 24" + ); +} + +#[test] +fn test_emerald_detection_invalid_timestamp_too_old() { + let mut data = vec![0u8; 24]; + let old_timestamp: f64 = 1000.0; // Way too old (around year 1902) + data[0..8].copy_from_slice(&old_timestamp.to_le_bytes()); + + assert!( + !Emerald::detect(&data), + "Should not detect data with timestamp before 1995" + ); +} + +#[test] +fn test_emerald_detection_invalid_timestamp_too_new() { + let mut data = vec![0u8; 24]; + let future_timestamp: f64 = 100000.0; // Way too far in future (around year 2173) + data[0..8].copy_from_slice(&future_timestamp.to_le_bytes()); + + assert!( + !Emerald::detect(&data), + "Should not detect data with timestamp after 2050" + ); +} + +#[test] +fn test_emerald_detection_divergent_timestamps() { + // Two records with timestamps more than 1 day apart + let mut data = vec![0u8; 48]; + + let timestamp1: f64 = 46022.5; + data[0..8].copy_from_slice(×tamp1.to_le_bytes()); + + let timestamp2: f64 = 46025.5; // 3 days later - suspicious + data[24..32].copy_from_slice(×tamp2.to_le_bytes()); + + assert!( + !Emerald::detect(&data), + "Should not detect data with timestamps >1 day apart" + ); +} + +#[test] +fn test_emerald_detection_other_formats() { + assert!( + !Emerald::detect(b"MLVLG"), + "Should not detect Speeduino MLG format" + ); + assert!( + !Emerald::detect(b" = log.channels.iter().map(|c| c.name()).collect(); + eprintln!("Diff channels file has: {:?}", channel_names); + + eprintln!( + "Emerald diff channels log: {} channels, {} records, {:.1}s duration", + log.channels.len(), + log.data.len(), + log.times.last().unwrap_or(&0.0) + ); +} + +// ============================================ +// Channel Tests +// ============================================ + +#[test] +fn test_emerald_channel_properties() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + for channel in &log.channels { + let name = channel.name(); + let unit = channel.unit(); + + // Names should be non-empty + assert!(!name.is_empty(), "Channel should have a name"); + + // Names should be printable + for c in name.chars() { + assert!( + c.is_ascii_graphic() || c == ' ' || c == '°', + "Channel name should contain printable chars: {:?}", + name + ); + } + + // Units should be valid + for c in unit.chars() { + assert!( + c.is_ascii_graphic() || c == ' ' || c == '°' || c == '%' || c == 'λ', + "Channel unit should contain valid chars: {:?}", + unit + ); + } + + eprintln!(" Channel: {} [{}]", name, unit); + } +} + +#[test] +fn test_emerald_expected_channels() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + let channel_names: Vec = log.channels.iter().map(|c| c.name()).collect(); + + // These files typically have standard channels + let has_rpm = channel_names.iter().any(|n| n.contains("RPM")); + let has_temp = channel_names + .iter() + .any(|n| n.contains("Temp") || n.contains("temp")); + + assert!(has_rpm, "Should have an RPM channel: {:?}", channel_names); + assert!( + has_temp, + "Should have a temperature channel: {:?}", + channel_names + ); +} + +#[test] +fn test_emerald_channel_data_extraction() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Test get_channel_data for each channel + for idx in 0..log.channels.len() { + let channel_data = log.get_channel_data(idx); + assert_eq!( + channel_data.len(), + log.data.len(), + "Channel {} data length should match record count", + idx + ); + } + + // Out of bounds should return empty + let oob = log.get_channel_data(999); + assert!(oob.is_empty(), "Out of bounds should return empty"); +} + +#[test] +fn test_emerald_find_channel_index() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Find first channel + if !log.channels.is_empty() { + let first_name = log.channels[0].name(); + let found = log.find_channel_index(&first_name); + assert_eq!(found, Some(0)); + } + + // Non-existent channel + let not_found = log.find_channel_index("NonExistentChannel12345"); + assert_eq!(not_found, None); +} + +// ============================================ +// Data Value Tests +// ============================================ + +#[test] +fn test_emerald_data_values_reasonable() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Find RPM channel and check values are reasonable + for (idx, channel) in log.channels.iter().enumerate() { + if channel.name().contains("RPM") { + let rpm_data = log.get_channel_data(idx); + let min = rpm_data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = rpm_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + eprintln!("RPM range: {} to {}", min, max); + + // RPM should be in reasonable range (0-15000) + assert!(min >= 0.0, "RPM min should be >= 0"); + assert!(max <= 15000.0, "RPM max should be <= 15000"); + break; + } + } + + // Find Coolant Temp and check values are reasonable + for (idx, channel) in log.channels.iter().enumerate() { + if channel.name().contains("Coolant") { + let temp_data = log.get_channel_data(idx); + let min = temp_data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = temp_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + eprintln!("Coolant temp range: {} to {} °C", min, max); + + // Temperature should be in reasonable range (-40°C to 200°C) + assert!(min >= -40.0, "Coolant temp min should be >= -40"); + assert!(max <= 200.0, "Coolant temp max should be <= 200"); + break; + } + } +} + +// ============================================ +// Metadata Tests +// ============================================ + +#[test] +fn test_emerald_metadata_extraction() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Check metadata + if let Meta::Emerald(meta) = &log.meta { + eprintln!("Source file: {}", meta.source_file); + eprintln!("Record count: {}", meta.record_count); + eprintln!("Duration: {:.1}s", meta.duration_seconds); + eprintln!("Sample rate: {:.1} Hz", meta.sample_rate_hz); + + assert!( + meta.record_count > 0, + "Should have positive record count: {}", + meta.record_count + ); + assert!( + meta.duration_seconds > 0.0, + "Should have positive duration: {}", + meta.duration_seconds + ); + assert!( + meta.sample_rate_hz > 0.0, + "Should have positive sample rate: {}", + meta.sample_rate_hz + ); + + // Sample rate should be reasonable (10-100 Hz typical) + assert!( + meta.sample_rate_hz >= 5.0 && meta.sample_rate_hz <= 200.0, + "Sample rate should be reasonable: {} Hz", + meta.sample_rate_hz + ); + } else { + panic!("Expected Emerald metadata variant"); + } +} + +// ============================================ +// Data Integrity Tests +// ============================================ + +#[test] +fn test_emerald_data_structure() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Verify times and data match + assert_eq!(log.times.len(), log.data.len()); + + // Verify each record has correct channel count + let channel_count = log.channels.len(); + for (i, record) in log.data.iter().enumerate() { + assert_eq!( + record.len(), + channel_count, + "Record {} should have {} values", + i, + channel_count + ); + } +} + +#[test] +fn test_emerald_timestamp_monotonicity() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + assert_monotonic_times(&log); +} + +#[test] +fn test_emerald_values_are_finite() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + assert_finite_values(&log); +} + +// ============================================ +// Timeline Tests +// ============================================ + +#[test] +fn test_emerald_timeline_validity() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + let times = log.get_times_as_f64(); + + if !times.is_empty() { + // First timestamp should be 0 (relative to start) + assert!( + times[0] == 0.0, + "First timestamp should be 0, got {}", + times[0] + ); + + // All timestamps should be finite and reasonable + for (i, &t) in times.iter().enumerate() { + assert!(t.is_finite(), "Timestamp {} should be finite", i); + assert!( + t < 10000.0, + "Timestamp {} should be reasonable (<10000s)", + i + ); + } + + // Last timestamp indicates duration + let duration = times.last().unwrap(); + eprintln!("Log duration: {:.1} seconds", duration); + } +} + +// ============================================ +// All Files Test +// ============================================ + +#[test] +fn test_emerald_all_example_files() { + let emerald_files = [EMERALD_IDLE_REV, EMERALD_SHORT_DRIVE, EMERALD_DIFF_CHANNELS]; + + for file_path in emerald_files { + if !example_file_exists(file_path) { + eprintln!("Skipping: {} not found", file_path); + continue; + } + + let data = read_example_binary(file_path); + + assert!( + Emerald::detect(&data), + "Should detect {} as Emerald format", + file_path + ); + + let result = Emerald::parse_file(Path::new(file_path)); + assert!(result.is_ok(), "Should parse {} without error", file_path); + + let log = result.unwrap(); + + assert_eq!( + log.channels.len(), + 8, + "{} should have 8 channels", + file_path + ); + assert!(!log.data.is_empty(), "{} should have data", file_path); + + eprintln!( + "{}: {} channels, {} records, {:.1}s", + file_path, + log.channels.len(), + log.data.len(), + log.times.last().unwrap_or(&0.0) + ); + } +} + +// ============================================ +// Error Handling Tests +// ============================================ + +#[test] +fn test_emerald_missing_lg2_file() { + // Try to parse a non-existent file + let result = Emerald::parse_file(Path::new("/nonexistent/path/test.lg1")); + + assert!(result.is_err(), "Should fail for missing files"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("lg2") || err_msg.contains("LG2"), + "Error should mention missing LG2 file: {}", + err_msg + ); +} + +#[test] +fn test_emerald_parse_invalid_binary_data() { + // Invalid data should fail detection + let invalid = b"NOT_EMERALD_FORMAT_DATA"; + assert!( + !Emerald::detect(invalid), + "Should not detect invalid data as Emerald" + ); +} + +// ============================================ +// Performance Tests +// ============================================ + +#[test] +fn test_emerald_parse_performance() { + if !example_file_exists(EMERALD_SHORT_DRIVE) { + eprintln!("Skipping test: {} not found", EMERALD_SHORT_DRIVE); + return; + } + + let start = std::time::Instant::now(); + let log = Emerald::parse_file(Path::new(EMERALD_SHORT_DRIVE)).expect("Should parse"); + let elapsed = start.elapsed(); + + eprintln!("Parsed {} records in {:?}", log.data.len(), elapsed); + + // Should complete in reasonable time (well under 1 second for small files) + assert!( + elapsed.as_secs() < 5, + "Parsing should complete in reasonable time" + ); +} + +// ============================================ +// Channel Configuration Variation Tests +// ============================================ + +#[test] +fn test_emerald_different_channel_configs() { + // Compare channel names between different log files + // to verify we correctly parse different channel configurations + + let files = [ + (EMERALD_IDLE_REV, "idle/rev"), + (EMERALD_DIFF_CHANNELS, "diff channels"), + ]; + + let mut configs: Vec<(String, Vec)> = Vec::new(); + + for (file_path, name) in files { + if !example_file_exists(file_path) { + eprintln!("Skipping: {} not found", file_path); + continue; + } + + let log = Emerald::parse_file(Path::new(file_path)).expect("Should parse"); + let channel_names: Vec = log.channels.iter().map(|c| c.name()).collect(); + + eprintln!("{} channels: {:?}", name, channel_names); + configs.push((name.to_string(), channel_names)); + } + + // If we have both files, verify they have different configurations + if configs.len() >= 2 { + let config1 = &configs[0].1; + let config2 = &configs[1].1; + + // They should have some different channels + let different = config1.iter().zip(config2.iter()).any(|(a, b)| a != b); + + assert!( + different, + "Different log files should have different channel configurations" + ); + } +} diff --git a/tests/parsers/mod.rs b/tests/parsers/mod.rs index 3097ae0..56a680b 100644 --- a/tests/parsers/mod.rs +++ b/tests/parsers/mod.rs @@ -8,6 +8,7 @@ pub mod aim_tests; pub mod ecumaster_tests; +pub mod emerald_tests; pub mod format_detection_tests; pub mod haltech_tests; pub mod link_tests; From 6e482b752df6e1842938752007d7165a9640e205 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Wed, 31 Dec 2025 23:22:36 -0500 Subject: [PATCH 12/13] Fixes doc step Signed-off-by: Cole Gentry --- src/parsers/emerald.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parsers/emerald.rs b/src/parsers/emerald.rs index 68a2fa1..3c37027 100644 --- a/src/parsers/emerald.rs +++ b/src/parsers/emerald.rs @@ -5,7 +5,7 @@ //! - .lg1 file: Binary file containing timestamped data records //! //! Format structure: -//! - LG2 file: INI-like format with [chan1] through [chan8] sections mapping to channel IDs +//! - LG2 file: INI-like format with \[chan1\] through \[chan8\] sections mapping to channel IDs //! - LG1 file: 24-byte records (8-byte OLE timestamp + 8 x 2-byte u16 values) //! //! The channel IDs map to specific ECU parameters (RPM, TPS, temperatures, etc.) @@ -378,7 +378,7 @@ impl Emerald { } /// Detect if text data is Emerald LG2 format (channel definitions) - /// LG2 files have [chan1] through [chan8] sections + /// LG2 files have \[chan1\] through \[chan8\] sections pub fn detect_lg2(data: &[u8]) -> bool { // Must be valid UTF-8 text let text = match std::str::from_utf8(data) { From 40c64f3630c98d456f25973a64ab25c2070ff48b Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Thu, 1 Jan 2026 12:27:53 -0500 Subject: [PATCH 13/13] Updates documentation Signed-off-by: Cole Gentry --- .claude/settings.json | 11 +++- README.md | 140 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 125 insertions(+), 26 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index d3077ae..99b5560 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,7 +19,16 @@ "Bash(mkdir:*)", "Bash(git config:*)", "Bash(/Users/colegentry/Development/UltraLog/.githooks/pre-commit)", - "Bash(./scripts/bump-version.sh:*)" + "Bash(./scripts/bump-version.sh:*)", + "Bash(mkdir:*)", + "Bash(xxd:*)", + "Bash(cat:*)", + "Bash(cargo run:*)", + "Bash(cargo doc:*)", + "Bash(cargo check:*)", + "Bash(while read line)", + "Bash(do echo \"=== $line ===\")", + "Bash(done)" ] } } \ No newline at end of file diff --git a/README.md b/README.md index 6861a6d..5c594c4 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,18 @@ Configurable units for 8 measurement categories: ### Additional Tools - **Scatter Plot** - XY scatter visualization for channel correlation analysis +- **Histogram** - 2D heatmap visualization with configurable grid sizes (10x10 to 25x25) for analyzing channel distributions +- **Analysis Tools** - Built-in signal processing and statistics: + - **Filters** - Moving average, Kalman filter, and other signal processing tools + - **Statistics** - Min/max, percentiles, standard deviation calculations + - **AFR Analysis** - Air-Fuel Ratio analysis with target comparison + - **Derived Channels** - Calculated channels from existing data - **Normalization Editor** - Create custom field name mappings for cross-ECU comparison - **Field Normalization** - Maps ECU-specific channel names to standard names (e.g., "Act_AFR" → "AFR") ### Accessibility - **Colorblind mode** - Wong's optimized color palette designed for deuteranopia, protanopia, and tritanopia +- **Font scaling** - Four font sizes (S, M, L, XL) for improved readability - **Custom font** - Clear, readable Outfit typeface - **Toast notifications** - Non-intrusive feedback for user actions @@ -170,10 +177,19 @@ Configurable units for 8 measurement categories: - **Supported data:** All logged channels with lap times, GPS data, and metadata ### Link ECU - Full Support + - **File type:** Link log format (`.llg`) - **Features:** Binary format parser for Link G4/G4+/G4X ECUs - **Supported data:** All ECU parameters including RPM, MAP, AFR, ignition timing, temperatures, and custom channels +### Emerald ECU - Full Support + +- **File type:** Binary format (`.lg1` data + `.lg2` channel definitions) +- **Features:** Native binary format parser for Emerald K6/M3D ECUs +- **Supported devices:** Emerald K6, M3D, and compatible ECU models +- **Supported data:** TPS, Air Temp, MAP, Lambda, Oil/Fuel Pressure, Oil/Fuel Temp, Exhaust Temp, Boost Target/Duty, RPM, Coolant Temp, Battery Voltage, Ignition Advance, Injector Pulse Width, and more +- **Note:** Both `.lg1` (data) and `.lg2` (channel definitions) files must be in the same directory + ### Coming Soon - MegaSquirt - AEM @@ -286,15 +302,28 @@ cargo build --release ## User Guide +### Interface Overview + +UltraLog v2.0 features a VS Code-style interface with an activity bar on the far left: + +**Activity Bar Panels:** +- **Files** (folder icon) - File list, drop zone, loaded files management +- **Channels** (list icon) - Channel selection, search, selected channel cards +- **Tools** (wrench icon) - Access to Scatter Plot, Histogram, and Analysis Tools +- **Settings** (gear icon) - All application settings in one place (units, display options, accessibility) + ### Loading Log Files -**Supported file extensions:** `.csv`, `.log`, `.txt`, `.mlg` +**Supported file extensions:** `.csv`, `.log`, `.txt`, `.mlg`, `.xrk`, `.drk`, `.llg`, `.lg1/.lg2` UltraLog automatically detects the ECU format based on file contents: - **Haltech:** Identified by `%DataLog%` header - **ECUMaster:** Identified by semicolon/tab-delimited CSV with `TIME` column - **RomRaider:** Identified by comma-delimited CSV starting with `Time` column - **Speeduino/rusEFI:** Identified by `MLVLG` binary header +- **AiM:** Identified by `