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); +}