diff --git a/.claude/settings.json b/.claude/settings.json
index 0d6ce43..99b5560 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -16,7 +16,19 @@
"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:*)",
+ "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/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..5c594c4 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust.


-
+
---
@@ -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 `Free and open source — no subscriptions, no licenses, just download and go.
-
v1.7.2
+
v2.0.0
Open Source
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 0000000..4878549
Binary files /dev/null and b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1 differ
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 0000000..5563241
Binary files /dev/null and b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1 differ
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 0000000..76263b8
Binary files /dev/null and b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1 differ
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
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 5823d37..cfdee2f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -11,13 +11,16 @@ 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};
+use crate::parsers::{
+ Aim, EcuMaster, EcuType, Emerald, Haltech, Link, Parseable, RomRaider, Speeduino,
+};
use crate::state::{
- ActiveTool, CacheKey, 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};
@@ -73,6 +76,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,
@@ -89,6 +94,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,
@@ -116,8 +124,21 @@ 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 ===
+ /// 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,
+ /// Selected category in analysis panel (None = show all)
+ pub(crate) analysis_selected_category: Option,
}
impl Default for UltraLogApp {
@@ -143,6 +164,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(),
@@ -150,6 +172,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(),
@@ -162,7 +185,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,
}
}
}
@@ -219,6 +248,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
// ========================================================================
@@ -379,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
@@ -474,6 +520,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));
@@ -1213,18 +1262,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 {
@@ -1295,6 +1404,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 {
@@ -1326,45 +1436,51 @@ 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)
+ // 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")
+ .resizable(false)
+ .min_height(60.0)
.show(ctx, |ui| {
- self.render_channel_selection(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 (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);
- });
- }
}
// Main content area - render based on active tool
@@ -1388,6 +1504,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/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/computed.rs b/src/computed.rs
index 8d62447..166a661 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 {
@@ -304,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/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/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/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/parsers/emerald.rs b/src/parsers/emerald.rs
new file mode 100644
index 0000000..3c37027
--- /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 cfb6a5e..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]] = &[
@@ -180,6 +181,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 +191,69 @@ impl ActiveTool {
match self {
ActiveTool::LogViewer => "Log Viewer",
ActiveTool::ScatterPlot => "Scatter Plots",
+ ActiveTool::Histogram => "Histogram",
+ }
+ }
+}
+
+/// 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 {
+ /// 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,
}
}
}
@@ -227,6 +293,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 +415,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 +439,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/activity_bar.rs b/src/ui/activity_bar.rs
new file mode 100644
index 0000000..96c0b10
--- /dev/null
+++ b/src/ui/activity_bar.rs
@@ -0,0 +1,240 @@
+//! 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 => {
+ // 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,
+ );
+
+ // 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/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 rectangular teeth
+ for i in 0..teeth {
+ let angle = (i as f32) * std::f32::consts::TAU / teeth as f32;
+
+ // 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_right = egui::pos2(
+ center.x + body_radius * angle_right.cos(),
+ center.y + body_radius * angle_right.sin(),
+ );
+
+ // 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 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 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/analysis_panel.rs b/src/ui/analysis_panel.rs
new file mode 100644
index 0000000..df69db4
--- /dev/null
+++ b/src/ui/analysis_panel.rs
@@ -0,0 +1,1151 @@
+//! Analysis Panel UI.
+//!
+//! 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 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,
+}
+
+/// 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", "AFR"),
+ ("Derived", "Derived"),
+];
+
+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(550.0)
+ .order(egui::Order::Foreground)
+ .show(ctx, |ui| {
+ // 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 {
+ // 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);
+ }
+ });
+
+ if !open {
+ self.show_analysis_panel = false;
+ }
+ }
+
+ /// 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
+ 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();
+
+ // 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;
+ 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| {
+ // 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() {
+ // 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(8.0);
+ }
+ }
+ }
+
+ // Analyzers section header
+ let category_label = match &self.analysis_selected_category {
+ None => "All Tools".to_string(),
+ Some(cat) => cat.clone(),
+ };
+
+ 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));
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // 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(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 == usize::MAX {
+ // Clear all
+ results.clear();
+ } else 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().size(14.0));
+ ui.label(
+ egui::RichText::new(&info.description)
+ .color(egui::Color32::GRAY)
+ .size(12.0),
+ );
+ });
+
+ 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, 50, 55))
+ .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..cfc029c 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;
@@ -254,6 +275,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 +302,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
- .iter()
- .enumerate()
- .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
- .map(|(i, v)| (i, *v))
- .unwrap();
- let (max_idx, max_val) = data
+ // Find min and max with their indices (filter out NaN values)
+ let valid_data: Vec<(usize, f64)> = data
.iter()
.enumerate()
- .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
+ .filter(|(_, v)| v.is_finite())
.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 +376,7 @@ impl UltraLogApp {
channel_cards.push(ChannelCardData {
color: color32,
display_name,
+ is_computed: selected.channel.is_computed(),
min_str,
max_str,
min_record,
@@ -348,14 +398,105 @@ impl UltraLogApp {
.corner_radius(5)
.inner_margin(10.0)
.show(ui, |ui| {
- ui.vertical(|ui| {
- ui.horizontal(|ui| {
- ui.label(
- egui::RichText::new(&card.display_name)
- .strong()
- .color(card.color),
+ // 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.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()
+ .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,
+ );
+ }
+ }
+ });
+ }
+
+ // 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("x")
+ .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);
}
@@ -363,66 +504,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)
- .small(),
- );
- ui.label(
- egui::RichText::new(min_str)
- .color(egui::Color32::LIGHT_GRAY),
- );
- 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)
- .small(),
- );
- ui.label(
- egui::RichText::new(max_str)
- .color(egui::Color32::LIGHT_GRAY),
- );
- 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,
- );
- }
- }
- });
- }
});
});
@@ -450,7 +531,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/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/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/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs
index 9092d35..585501c 100644
--- a/src/ui/computed_channels_manager.rs
+++ b/src/ui/computed_channels_manager.rs
@@ -1,13 +1,16 @@
//! Computed Channels Manager UI.
//!
-//! Provides a window for users to manage their computed channel library
+//! Provides a simplified window for users to manage their computed channel library
//! and apply computed channels to the active log file.
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};
@@ -20,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();
}
});
@@ -40,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
@@ -58,100 +137,50 @@ 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;
+
+ let search_lower = self.computed_channels_search.to_lowercase();
egui::ScrollArea::vertical()
.id_salt("library_templates_scroll")
- .max_height(250.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| {
- ui.horizontal(|ui| {
- 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());
- }
- },
- );
- });
- });
+ // 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;
+ }
+ }
+
+ 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);
}
});
@@ -168,266 +197,339 @@ 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);
}
+
+ 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("x")
+ .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);
+ }
+
+ 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),
+ );
+ }
+ }
- egui::CollapsingHeader::new("Example Formulas")
- .default_open(true)
+ /// Render the help popup with examples and syntax reference
+ fn render_computed_channels_help(&mut self, ctx: &egui::Context) {
+ let mut open = true;
+
+ 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| {
- ui.label(
- egui::RichText::new("Common computed channel examples:")
- .color(egui::Color32::GRAY),
- );
+ // 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)");
- // 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),
- );
- });
-
- 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),
- );
- });
+ 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("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("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");
- 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("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");
- // 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(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
- 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;
@@ -449,17 +551,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/export.rs b/src/ui/export.rs
index a25df9c..1eaa21d 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,1194 @@ 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
+ 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))
+ }
+
+ /// 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
+ #[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] {
+ // 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
+ #[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];
+ 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
+ #[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];
+ 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
new file mode 100644
index 0000000..5d3a67e
--- /dev/null
+++ b/src/ui/files_panel.rs
@@ -0,0 +1,287 @@
+//! Files panel - file management, loading, and file list.
+
+use eframe::egui;
+
+use crate::app::UltraLogApp;
+use crate::state::{LoadingState, SUPPORTED_EXTENSIONS};
+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("x")
+ .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);
+
+ let extensions_text = SUPPORTED_EXTENSIONS
+ .iter()
+ .map(|ext| ext.to_uppercase())
+ .collect::>()
+ .join(" • ");
+ ui.label(
+ egui::RichText::new(extensions_text)
+ .color(text_gray)
+ .size(self.scaled_font(11.0)),
+ );
+ });
+ });
+ }
+}
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);
}
diff --git a/src/ui/histogram.rs b/src/ui/histogram.rs
new file mode 100644
index 0000000..c93ffd0
--- /dev/null
+++ b/src/ui/histogram.rs
@@ -0,0 +1,1120 @@
+//! 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
+
+/// 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 {
+ 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(self.scaled_font(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;
+
+ // 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(font_15));
+ 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(font_14),
+ )
+ .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(font_14),
+ )
+ .clicked()
+ {
+ new_x = Some(*idx);
+ }
+ }
+ });
+
+ ui.add_space(16.0);
+
+ // Y Axis selector
+ ui.label(egui::RichText::new("Y Axis:").size(font_15));
+ 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(font_14),
+ )
+ .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(font_14),
+ )
+ .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(font_15));
+ 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(font_14),
+ )
+ .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(font_14),
+ )
+ .clicked()
+ {
+ new_z = Some(*idx);
+ }
+ }
+ });
+ });
+
+ ui.add_space(20.0);
+
+ // Grid size selector
+ 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(font_14))
+ .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(font_14),
+ )
+ .clicked()
+ {
+ new_grid_size = Some(size);
+ }
+ }
+ });
+
+ ui.add_space(20.0);
+
+ // Mode toggle
+ ui.label(egui::RichText::new("Mode:").size(font_15));
+ if ui
+ .selectable_label(
+ current_mode == HistogramMode::AverageZ,
+ egui::RichText::new("Average Z").size(font_14),
+ )
+ .clicked()
+ {
+ new_mode = Some(HistogramMode::AverageZ);
+ }
+ if ui
+ .selectable_label(
+ current_mode == HistogramMode::HitCount,
+ egui::RichText::new("Hit Count").size(font_14),
+ )
+ .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();
+
+ // 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),
+ _ => {
+ 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(font_16),
+ 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(font_16),
+ 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 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(cell_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(font_10),
+ 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(font_13),
+ 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(font_10),
+ 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(font_13),
+ 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();
+
+ // 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;
+ 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(font_12),
+ 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 with selected cell info
+ ui.add_space(8.0);
+ 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)
+ 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 cell statistics
+ fn render_histogram_legend(
+ &self,
+ ui: &mut egui::Ui,
+ min_value: f64,
+ max_value: f64,
+ mode: HistogramMode,
+ selected_cell: Option<&SelectedHistogramCell>,
+ ) {
+ let font_12 = self.scaled_font(12.0);
+ let font_13 = self.scaled_font(13.0);
+
+ 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(font_13)
+ .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(font_13)
+ .color(egui::Color32::WHITE),
+ );
+ });
+ });
+
+ ui.add_space(16.0);
+
+ // 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(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("Click a cell to view statistics")
+ .size(font_12)
+ .italics()
+ .color(egui::Color32::GRAY),
+ );
+ });
+ }
+ });
+ }
+
+ /// 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;
+
+ // 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(font_14)
+ .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(font_13)
+ .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(font_12)
+ .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(font_12)
+ .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(font_12)
+ .italics()
+ .color(egui::Color32::GRAY),
+ );
+ }
+ }
+}
diff --git a/src/ui/menu.rs b/src/ui/menu.rs
index 30a6b41..1bf10b7 100644
--- a/src/ui/menu.rs
+++ b/src/ui/menu.rs
@@ -1,23 +1,24 @@
-//! 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::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
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,16 +27,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));
let is_loading = matches!(self.loading_state, LoadingState::Loading(_));
// 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()
@@ -49,400 +51,167 @@ impl UltraLogApp {
ui.separator();
- // Export submenu
+ // 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();
- ui.add_enabled_ui(has_chart_data, |ui| {
- ui.menu_button("📤 Export", |ui| {
- // Increase font size for submenu items
+ 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| {
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();
+ .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14));
+
+ if self.active_tool == ActiveTool::Histogram && has_histogram_data {
+ if ui.button("Export Histogram as PDF...").clicked() {
+ self.export_histogram_pdf();
+ ui.close();
+ }
+ } else if has_chart_data {
+ 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();
+ }
}
});
});
});
- // 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(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));
+
+ // Tool modes
+ ui.label(
+ egui::RichText::new("Tool Mode")
+ .size(font_14)
+ .color(egui::Color32::GRAY),
+ );
- // Cursor Tracking toggle
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()
+ {
+ ui.close();
+ }
+ if ui
+ .radio_value(&mut self.active_tool, ActiveTool::Histogram, "Histogram")
+ .on_hover_text("⌘3")
.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
+ // Panel navigation
+ ui.label(
+ egui::RichText::new("Side Panel")
+ .size(font_14)
+ .color(egui::Color32::GRAY),
+ );
+
if ui
- .checkbox(&mut self.field_normalization, "📝 Field Normalization")
- .on_hover_text("Standardize channel names across different ECU types")
+ .radio_value(&mut self.active_panel, ActivePanel::Files, "Files")
+ .on_hover_text("⌘⇧F")
.clicked()
{
ui.close();
}
-
- // Edit mappings button
- if ui.button(" Edit Mappings...").clicked() {
- self.show_normalization_editor = true;
+ if ui
+ .radio_value(&mut self.active_panel, ActivePanel::Channels, "Channels")
+ .on_hover_text("⌘⇧C")
+ .clicked()
+ {
ui.close();
}
-
- ui.separator();
-
- // Auto-update preference
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::Tools, "Tools")
+ .on_hover_text("⌘⇧T")
.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(14.0));
- ui.style_mut()
- .text_styles
- .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0));
-
- // 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));
- 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(14.0));
- 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(14.0));
- 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(14.0));
- 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(14.0));
- 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(14.0));
- 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(14.0));
- 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(14.0));
- 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(14.0));
- ui.style_mut()
- .text_styles
- .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0));
-
- if ui.button("ƒ(x) Computed Channels...").clicked() {
- self.show_computed_channels_manager = true;
+ 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(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() {
+ 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();
}
@@ -456,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 bf02d77..84ba92a 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -1,25 +1,50 @@
//! 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;
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/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/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/sidebar.rs b/src/ui/sidebar.rs
index efca203..9453cf1 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,22 +186,26 @@ 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);
ui.label(
- egui::RichText::new("CSV • LOG • TXT • MLG")
+ egui::RichText::new("CSV • LOG • TXT • MLG • LLG • XRK • DRK")
.color(text_gray)
- .size(11.0),
+ .size(self.scaled_font(11.0)),
);
});
});
@@ -209,10 +213,23 @@ 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);
+ // 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()
@@ -224,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")
@@ -246,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);
@@ -258,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| {
@@ -270,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..95f5bcf 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("×")
+ egui::RichText::new("x")
.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 53dd4ea..c0472d9 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(
@@ -41,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 140723b..b036b55 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;
@@ -44,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/tools_panel.rs b/src/ui/tools_panel.rs
new file mode 100644
index 0000000..1ecf904
--- /dev/null
+++ b/src/ui/tools_panel.rs
@@ -0,0 +1,374 @@
+//! 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_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_png, |ui| {
+ let btn = egui::Frame::NONE
+ .fill(if can_export_png {
+ 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_png {
+ egui::Color32::WHITE
+ } else {
+ egui::Color32::GRAY
+ })
+ .size(font_14),
+ );
+ });
+
+ 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_png {
+ ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
+ }
+ });
+
+ // PDF Export
+ 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() {
+ match self.active_tool {
+ ActiveTool::LogViewer => self.export_chart_pdf(),
+ ActiveTool::ScatterPlot => self.export_scatter_plot_pdf(),
+ ActiveTool::Histogram => self.export_histogram_pdf(),
+ }
+ }
+
+ if btn.response.hovered() && can_export_pdf {
+ ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
+ }
+ });
+ });
+
+ // 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 chart export")
+ .size(font_12)
+ .color(egui::Color32::from_rgb(100, 100, 100))
+ .italics(),
+ );
+ }
+ });
+ }
+}
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)),
);
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/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]
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);
+}
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;