diff --git a/CLAUDE.md b/CLAUDE.md index a991079..e2612c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,8 @@ src/ ├── settings_panel.rs # Consolidated settings (display, units, normalization, updates) ├── tool_properties_panel.rs # Dynamic panel showing controls for the active tool (channels / histogram / scatter) ├── analysis_panel.rs # Window for running analysis algorithms (src/analysis) on the active log - ├── table_generator.rs # Table generator window (setup / heatmap results / event inspector / export) + ├── table_generator.rs # Table tools: Tool Properties setup + central heatmap / inspector view + ├── table_export.rs # PNG / PDF export for generated tables (pure renderers + app wrappers) ├── data_panel.rs # Right-side data panel hosting DataWidget panes (rail, header, hide/restore) ├── widgets/ │ ├── mod.rs # DataWidget trait + static widget registry @@ -145,7 +146,7 @@ src/ ├── toast.rs # Toast notification system ├── icons.rs # Custom icon drawing utilities ├── tab_bar.rs # Multi-file tab interface - ├── tool_switcher.rs # Switch between Log Viewer, Scatter Plot, and Histogram tools + ├── tool_switcher.rs # Switch between the five tools (ActiveTool::ALL) ├── scatter_plot.rs # XY scatter plot visualization ├── histogram.rs # 2D histogram/heatmap view for channel distributions ├── export.rs # PNG and PDF export functionality @@ -291,6 +292,21 @@ low cells are never interpolated and export blank. - **Axis cap** - `binning::MAX_BINS_PER_AXIS` (64) keeps any future MCP payload well under the 512 KiB response guard. `histogram.rs` now calls `binning::uniform_bin` for its cell math so both tools agree on boundaries. +- **Tools, not a window** - `ActiveTool::LambdaDelay` / `ActiveTool::AccelEnrich` render like + Histogram: setup in the Tool Properties panel (`render_table_tool_properties`), results in the + central panel (`render_table_tool_view`). `ActiveTool::generator_kind()` is the one helper the + match sites use, and `ActiveTool::ALL` fixes the switcher / View-menu / Cmd+1..5 order, so a new + tool is appended there and nowhere else. Tool switches go through `UltraLogApp::set_active_tool` + (analytics + table selection reset), not direct assignment. +- **State is app-level, not per-tab** - unlike `Tab::histogram_state`, `UltraLogApp::table_generator` + holds the accumulators, mappings and export options for both generators. The tables are + multi-log by design, so the active tab only decides which file *Run / Add current file* reads; + switching tabs or tools does not lose a table. +- **PNG has no text** - the crate has no font rasterizer, so `table_export::render_table_png` + draws cells and grid lines only (same as the histogram PNG). `render_table_pdf` draws values, + counts and axis labels with built-in Helvetica, whose encoding turns `~` into an arrow, so the + confidence markers are `*` (medium) and `?` (low). Both go through `tables::export::cell_value` + so PNG, PDF, CSV and clipboard blank the same cells and convert delay units identically. - **Auto-suggestion** (`channel_map::suggest_mapping`) - normalization hit (100) → strong name hints (50) → spec category + hint (60) → generic hints (40), then a data-plausibility veto on the channel median; `overall|avg|average` names lose 10 points so a single sensor beats an averaged @@ -372,7 +388,7 @@ UI rendering is split into focused modules that implement methods on `UltraLogAp - **`toast.rs`** - Toast notification overlay for user feedback - **`icons.rs`** - Custom icon drawing (upload icon for drop zone) - **`tab_bar.rs`** - Chrome-style tabs for multi-file support -- **`tool_switcher.rs`** - Switch between Log Viewer, Scatter Plot, and Histogram tools +- **`tool_switcher.rs`** - Switch between the five tools in `ActiveTool::ALL` (Log Viewer, Scatter Plot, Histogram, Lambda Delay, Accel Enrichment) - **`scatter_plot.rs`** - XY scatter plot for channel correlation analysis - **`histogram.rs`** - 2D histogram/heatmap view of channel distributions, with configurable cell coloring (average Z-value or hit count) - **`export.rs`** - PNG and PDF export with chart rendering @@ -553,7 +569,7 @@ The Track Map widget can draw map tile backgrounds. Tiles are **opt-in** (off by - **Multi-ECU Support** - Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD Tuning, Motorsport Electronics, RaceChrono, Woolich Racing Tuned, BlueDriver, DynamicEFI, and Locomotive log formats - **Computed Channels** - Create virtual channels from mathematical formulas with time-shifting (e.g., `RPM[-1]`, `Boost@-0.5s`) - **Analysis Algorithms** - AFR/Lambda drift and zone detection, derived metrics (VE, injector duty cycle), signal filters, and descriptive statistics (`src/analysis/`) -- **Table Generators** - Lambda delay and acceleration enrichment tuning tables mined from one or more logs, with auto-suggested channel roles, confidence-tiered cells, an event inspector, and CSV/clipboard export (`src/analysis/tables/`, `src/ui/table_generator.rs`) +- **Table Generators** - Lambda delay and acceleration enrichment tuning tables mined from one or more logs, each a top-level tool beside Histogram, with auto-suggested channel roles, confidence-tiered cells, an event inspector, and CSV/clipboard/PNG/PDF export (`src/analysis/tables/`, `src/ui/table_generator.rs`, `src/ui/table_export.rs`) - **GPS Track Map** - Right-side data panel with a track map: lap detection, channel-colored polyline (Viridis/Turbo with editable range), hover-scrub/click-seek cursor sync, and opt-in Esri/OSM tile backgrounds (`src/ui/widgets/track_map.rs`, `src/tiles.rs`, `src/laps.rs`). GPS coordinate encodings are auto-detected and normalized to decimal degrees (`GpsCoordSpec` in `src/laps.rs`): NMEA `DDMM.mmmm`, milli/micro/1e-7-scaled integer degrees, and 0-360 longitude. Detection is conservative - values already in valid degree ranges are never transformed, and radians are deliberately not detected (ambiguous with genuine near-equator degree tracks). - **Claude Desktop / MCP Integration** - Embedded MCP server (`src/mcp/`) lets Claude control the running app over `http://localhost:52385/mcp` — select channels, add computed channels, query log data - **Unit Preferences** - Users can select display units for temperature, pressure, speed, distance, fuel economy, volume, flow rate, and acceleration @@ -578,7 +594,7 @@ Handled in `UltraLogApp::handle_keyboard_shortcuts` (`src/app.rs`); ignored whil - **Cmd/Ctrl+O** - Open file - **Cmd/Ctrl+W** - Close current tab - **Cmd/Ctrl+,** - Open Settings panel -- **Cmd/Ctrl+1/2/3** - Switch tool (Log Viewer / Scatter Plot / Histogram) +- **Cmd/Ctrl+1..5** - Switch tool, in `ActiveTool::ALL` order (Log Viewer / Scatter Plot / Histogram / Lambda Delay / Accel Enrichment) - **Cmd/Ctrl+Shift+F/C/T** - Switch side panel (Files / Tool Properties / Tools) - **Arrow Left/Right** - Step cursor one record (Shift = 10 records) - **Home/End** - Jump cursor to start/end of log diff --git a/Cargo.lock b/Cargo.lock index 808b0ac..8138730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4578,7 +4578,7 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ultralog" -version = "2.14.1" +version = "2.15.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index a22e105..a221b77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "2.14.1" +version = "2.15.0" edition = "2024" # egui/eframe 0.36 is the binding constraint on the minimum supported Rust # version; edition 2024 itself only needs 1.85. diff --git a/README.md b/README.md index 2a09060..7c98168 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust. ![CI](https://github.com/ClassicMiniDIY/UltraLog/actions/workflows/ci.yml/badge.svg) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) -![Version](https://img.shields.io/badge/version-2.14.1-green.svg) +![Version](https://img.shields.io/badge/version-2.15.0-green.svg) --- @@ -79,7 +79,7 @@ Configurable units for 8 measurement categories: - **Scatter Plot** - XY scatter visualization for channel correlation analysis - **Histogram** - 2D heatmap visualization with configurable grid sizes (10x10 to 25x25) for analyzing channel distributions - **MCP Server** - Built-in Model Context Protocol server lets Claude Desktop (or any MCP client) control UltraLog — load files, select channels, get stats, create computed channels, and run analysis via `http://localhost:52453/mcp` -- **Table Generators** - Mine logged events into tuning tables (Tools panel → Table Generators): +- **Table Generators** - Mine logged events into tuning tables. Each is a tool tab beside Histogram (⌘4 / ⌘5), with setup in the Tool Properties panel and CSV, clipboard, PNG and PDF export: - **Lambda Delay Table** - time from injector pulse-width steps to the wideband response, binned by RPM × load, for closed-loop O2 delay tables (ms, engine cycles or ignition events) - **Acceleration Enrichment Table** - tip-in lean/rich excursion depth, duration and a suggested starting correction, binned by RPM × throttle rate, with lambda-delay compensation from a table generated in the same session - Auto-suggested channel roles, per-cell sample counts and confidence, multi-log accumulation, event inspector with jump-to-time, CSV export and tab-separated clipboard copy diff --git a/docs/index.html b/docs/index.html index 8cd0fc8..2ec9e0a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -9,9 +9,9 @@ UltraLog - Free ECU Log Viewer & Analyzer | Haltech, ECUMaster, Speeduino, AiM & More + content="UltraLog is a free, open-source ECU datalog viewer built with Rust. Analyze logs from Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, BlueDriver, MegaSquirt, TunerStudio MSL, MHD, Woolich, and RaceChrono lap-timing sessions. Features include lambda delay and acceleration enrichment table generators, stacked plot areas, histogram heatmaps, scatter plots, computed channels, MCP server for AI integration, and professional analysis tools. Download for Windows, macOS, and Linux."> + content="ECU log viewer, datalog analyzer, Haltech NSP, ECUMaster EMU Pro, RomRaider, Speeduino MLG, rusEFI, AiM XRK DRK, Link ECU LLG, Emerald K6 M3D, automotive tuning software, engine tuning, AFR analysis, boost log, free tuning software, open source ECU, car data logger, dyno analysis, motorsport data, fuel map analysis, ignition timing, lambda analysis, volumetric efficiency, injector duty cycle, Butterworth filter, scatter plot, histogram heatmap, computed channels, Rust application, stacked plots, MCP server, Model Context Protocol, Claude Desktop, BlueDriver OBD-II, AI log analysis, RaceChrono CSV, TunerStudio MSL, RealDash log, lap timing analysis, track day data, GPS track map, lambda delay table, O2 delay table, acceleration enrichment, accel enrichment table, transient fuel tuning"> @@ -107,13 +107,13 @@ "@type": "SoftwareApplication", "name": "UltraLog", "alternateName": ["UltraLog ECU Viewer", "UltraLog Datalog Analyzer"], - "description": "A high-performance, cross-platform ECU log viewer and analyzer built with Rust. Supports Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD, Motorsport Electronics, RaceChrono, Woolich, BlueDriver, DynamicEFI, and Locomotive log formats with advanced analysis tools.", + "description": "A high-performance, cross-platform ECU log viewer and analyzer built with Rust. Supports Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD, Motorsport Electronics, RaceChrono, Woolich, BlueDriver, DynamicEFI, and Locomotive log formats with advanced analysis tools, including lambda delay and acceleration enrichment table generators.", "url": "https://ultralog.co/", "applicationCategory": "UtilitiesApplication", "applicationSubCategory": "Automotive Software", "operatingSystem": ["Windows 10", "Windows 11", "macOS", "Linux"], - "softwareVersion": "2.14.1", - "releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.14.1", + "softwareVersion": "2.15.0", + "releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.15.0", "downloadUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest", "installUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest", "screenshot": [ @@ -147,6 +147,8 @@ }, "featureList": [ "Multi-channel time series visualization", + "Lambda delay table generator", + "Acceleration enrichment table generator", "2D histogram heatmaps", "XY scatter plot analysis", "Computed math channels with formulas", @@ -1427,7 +1429,7 @@

Unlock Your Performanc
New - v2.14.1 + v2.15.0 Open Source @@ -1530,8 +1532,22 @@

Computed Math Channels

What's New

-

TunerStudio MSL imports, RaceChrono lap-timing sessions, GPS track maps with satellite imagery, stacked plots, AI-powered analysis via MCP, and more

+

Lambda delay and acceleration enrichment table generators, TunerStudio MSL imports, RaceChrono lap-timing sessions, GPS track maps, AI-powered analysis via MCP, and more

+
+
+
+

Lambda Delay Tables

+
+

A new tool that mines your logs for injector pulse-width steps and measures how long the wideband takes to respond, binned by RPM and load. Every cell carries a sample count and confidence, sparse cells stay blank, and the result exports as CSV, clipboard, PNG or PDF in milliseconds, engine cycles or ignition events — ready for your ECU's closed-loop O2 delay table.

+
+
+
+
+

Accel Enrichment Tables

+
+

Detects tip-ins, shifts the lambda window by the measured delay, and reports the lean or rich excursion against target with a suggested starting correction, binned by RPM and throttle rate. Runs across several logs at once and composes with a lambda delay table generated in the same session.

+
diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 1e65b0f..8af033b 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -3,7 +3,7 @@ xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> https://ultralog.co/ - 2026-09-15 + 2026-09-18 weekly 1.0 diff --git a/i18n/ar.yaml b/i18n/ar.yaml index 2bd7b15..ac4bd74 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -9,12 +9,16 @@ menu: export_png: "تصدير كـ PNG..." export_pdf: "تصدير كـ PDF..." export_histogram_pdf: "تصدير المدرج التكراري كـ PDF..." + export_table_png: "تصدير الجدول كـ PNG..." + export_table_pdf: "تصدير الجدول كـ PDF..." view: "عرض" show_grid: "إظهار الشبكة" tool_mode: "وضع الأداة" log_viewer: "عارض السجل" scatter_plots: "المخططات المبعثرة" histogram: "المدرج التكراري" + lambda_delay: "تأخير لامدا" + accel_enrich: "إثراء التسارع" side_panel: "اللوحة الجانبية" files: "الملفات" channels: "القنوات" @@ -170,6 +174,8 @@ toast: export_failed: "فشل التصدير: %{error}" histogram_exported_png: "تم تصدير المدرج التكراري كـ PNG" histogram_exported_pdf: "تم تصدير المدرج التكراري كـ PDF" + table_exported_png: "تم تصدير الجدول كـ PNG (الخلايا فقط؛ استخدم CSV للقيم)" + table_exported_pdf: "تم تصدير الجدول كـ PDF" scatter_exported_png: "تم تصدير المخطط المبعثر كـ PNG" scatter_exported_pdf: "تم تصدير المخطط المبعثر كـ PDF" failed_to_save: "فشل الحفظ: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "استخراج الأحداث المسجلة في جداول تأخير لامدا وإثراء التسارع." load_file_hint: "حمّل ملفًا لإنشاء جداول" status: "%{logs} سجل(ات)، %{events} أحداث" - window_title: "مولدات الجداول" + configure_hint: "عيّن القنوات في لوحة خصائص الأداة ثم انقر تشغيل." setup_header: "الإعداد - %{file}" channels: "القنوات" auto_detect: "الكشف التلقائي" @@ -401,6 +407,8 @@ tools: log_viewer: "عارض السجل" scatter_plots: "المخططات المبعثرة" histogram: "المدرج التكراري" + lambda_delay: "تأخير لامدا" + accel_enrich: "إثراء التسارع" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/bn.yaml b/i18n/bn.yaml index 49584f3..24af5e9 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -9,12 +9,16 @@ menu: export_png: "PNG হিসেবে রপ্তানি..." export_pdf: "PDF হিসেবে রপ্তানি..." export_histogram_pdf: "হিস্টোগ্রাম PDF হিসেবে রপ্তানি..." + export_table_png: "টেবিল PNG হিসেবে রপ্তানি..." + export_table_pdf: "টেবিল PDF হিসেবে রপ্তানি..." view: "দৃশ্য" show_grid: "গ্রিড দেখান" tool_mode: "টুল মোড" log_viewer: "লগ ভিউয়ার" scatter_plots: "স্ক্যাটার প্লট" histogram: "হিস্টোগ্রাম" + lambda_delay: "ল্যাম্বডা বিলম্ব" + accel_enrich: "ত্বরণ সমৃদ্ধকরণ" side_panel: "সাইড প্যানেল" files: "ফাইলসমূহ" channels: "চ্যানেলসমূহ" @@ -170,6 +174,8 @@ toast: export_failed: "রপ্তানি ব্যর্থ: %{error}" histogram_exported_png: "হিস্টোগ্রাম PNG হিসেবে রপ্তানি হয়েছে" histogram_exported_pdf: "হিস্টোগ্রাম PDF হিসেবে রপ্তানি হয়েছে" + table_exported_png: "টেবিল PNG হিসেবে রপ্তানি হয়েছে (শুধু সেল; মানের জন্য CSV)" + table_exported_pdf: "টেবিল PDF হিসেবে রপ্তানি হয়েছে" scatter_exported_png: "স্ক্যাটার প্লট PNG হিসেবে রপ্তানি হয়েছে" scatter_exported_pdf: "স্ক্যাটার প্লট PDF হিসেবে রপ্তানি হয়েছে" failed_to_save: "সংরক্ষণ ব্যর্থ: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "লগ করা ইভেন্টগুলি ল্যাম্বডা বিলম্ব এবং ত্বরণ সমৃদ্ধকরণ টেবিলে মাইন করুন।" load_file_hint: "টেবিল তৈরি করতে একটি ফাইল লোড করুন" status: "%{logs} লগ(গুলি), %{events} ইভেন্ট" - window_title: "টেবিল জেনারেটর" + configure_hint: "টুল প্রপার্টিজ প্যানেলে চ্যানেল ম্যাপ করুন, তারপর রান ক্লিক করুন।" setup_header: "সেটআপ - %{file}" channels: "চ্যানেল" auto_detect: "স্বয়ংক্রিয় সনাক্ত" @@ -401,6 +407,8 @@ tools: log_viewer: "লগ ভিউয়ার" scatter_plots: "স্ক্যাটার প্লট" histogram: "হিস্টোগ্রাম" + lambda_delay: "ল্যাম্বডা বিলম্ব" + accel_enrich: "ত্বরণ সমৃদ্ধকরণ" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/de.yaml b/i18n/de.yaml index 7f4156f..3e45c47 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -9,12 +9,16 @@ menu: export_png: "Als PNG exportieren..." export_pdf: "Als PDF exportieren..." export_histogram_pdf: "Histogramm als PDF exportieren..." + export_table_png: "Tabelle als PNG exportieren..." + export_table_pdf: "Tabelle als PDF exportieren..." view: "Ansicht" show_grid: "Gitter anzeigen" tool_mode: "Werkzeugmodus" log_viewer: "Log-Betrachter" scatter_plots: "Streudiagramme" histogram: "Histogramm" + lambda_delay: "Lambda-Verzögerung" + accel_enrich: "Beschleunigungsanreicherung" side_panel: "Seitenleiste" files: "Dateien" channels: "Kanäle" @@ -170,6 +174,8 @@ toast: export_failed: "Export fehlgeschlagen: %{error}" histogram_exported_png: "Histogramm als PNG exportiert" histogram_exported_pdf: "Histogramm als PDF exportiert" + table_exported_png: "Tabelle als PNG exportiert (nur Zellen; CSV für Werte)" + table_exported_pdf: "Tabelle als PDF exportiert" scatter_exported_png: "Streudiagramm als PNG exportiert" scatter_exported_pdf: "Streudiagramm als PDF exportiert" failed_to_save: "Speichern fehlgeschlagen: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Protokollierte Ereignisse in Lambda-Verzögerungs- und Anreicherungstabellen abbauen." load_file_hint: "Laden Sie eine Datei, um Tabellen zu generieren" status: "%{logs} Log(s), %{events} Ereignisse" - window_title: "Tabellengeneratoren" + configure_hint: "Kanäle im Werkzeugeigenschaften-Panel zuordnen, dann Ausführen klicken." setup_header: "Setup - %{file}" channels: "Kanäle" auto_detect: "Automatisch erkennen" @@ -401,6 +407,8 @@ tools: log_viewer: "Log-Betrachter" scatter_plots: "Streudiagramme" histogram: "Histogramm" + lambda_delay: "Lambda-Verzögerung" + accel_enrich: "Beschleunigungsanreicherung" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/en.yaml b/i18n/en.yaml index 33c2731..7d58d1e 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -9,12 +9,16 @@ menu: export_png: "Export as PNG..." export_pdf: "Export as PDF..." export_histogram_pdf: "Export Histogram as PDF..." + export_table_png: "Export Table as PNG..." + export_table_pdf: "Export Table as PDF..." view: "View" show_grid: "Show Grid" tool_mode: "Tool Mode" log_viewer: "Log Viewer" scatter_plots: "Scatter Plots" histogram: "Histogram" + lambda_delay: "Lambda Delay" + accel_enrich: "Accel Enrichment" side_panel: "Side Panel" files: "Files" channels: "Channels" @@ -170,6 +174,8 @@ toast: export_failed: "Export failed: %{error}" histogram_exported_png: "Histogram exported as PNG" histogram_exported_pdf: "Histogram exported as PDF" + table_exported_png: "Table exported as PNG (cells only; use CSV for values)" + table_exported_pdf: "Table exported as PDF" scatter_exported_png: "Scatter plot exported as PNG" scatter_exported_pdf: "Scatter plot exported as PDF" failed_to_save: "Failed to save: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Mine logged events into lambda delay and acceleration enrichment tables." load_file_hint: "Load a file to generate tables" status: "%{logs} log(s), %{events} events" - window_title: "Table Generators" + configure_hint: "Map the channels in the Tool Properties panel, then click Run." setup_header: "Setup - %{file}" channels: "Channels" auto_detect: "Auto-detect" @@ -401,6 +407,8 @@ tools: log_viewer: "Log Viewer" scatter_plots: "Scatter Plots" histogram: "Histogram" + lambda_delay: "Lambda Delay" + accel_enrich: "Accel Enrichment" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/es.yaml b/i18n/es.yaml index f1e1e6a..9029a46 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exportar como PNG..." export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." + export_table_png: "Exportar Tabla como PNG..." + export_table_pdf: "Exportar Tabla como PDF..." view: "Vista" show_grid: "Mostrar cuadrícula" tool_mode: "Modo de Herramienta" log_viewer: "Visor de Log" scatter_plots: "Graficos de Dispersion" histogram: "Histograma" + lambda_delay: "Retardo Lambda" + accel_enrich: "Enriquecimiento de Aceleración" side_panel: "Panel Lateral" files: "Archivos" channels: "Canales" @@ -170,6 +174,8 @@ toast: export_failed: "Error de exportacion: %{error}" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" + table_exported_png: "Tabla exportada como PNG (solo celdas; use CSV para valores)" + table_exported_pdf: "Tabla exportada como PDF" scatter_exported_png: "Grafico de dispersion exportado como PNG" scatter_exported_pdf: "Grafico de dispersion exportado como PDF" failed_to_save: "Error al guardar: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "Minar eventos registrados en tablas de retraso lambda y enriquecimiento de aceleración." load_file_hint: "Cargue un archivo para generar tablas" status: "%{logs} registro(s), %{events} eventos" - window_title: "Generadores de Tablas" + configure_hint: "Asigne los canales en el panel Propiedades de Herramienta y pulse Ejecutar." setup_header: "Configuración - %{file}" channels: "Canales" auto_detect: "Detección automática" @@ -402,6 +408,8 @@ tools: log_viewer: "Visor de Log" scatter_plots: "Graficos de Dispersion" histogram: "Histograma" + lambda_delay: "Retardo Lambda" + accel_enrich: "Enriquecimiento de Aceleración" # Barra de actividad (src/ui/activity_bar.rs) activity: diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 9625145..59446eb 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exporter en PNG..." export_pdf: "Exporter en PDF..." export_histogram_pdf: "Exporter l'histogramme en PDF..." + export_table_png: "Exporter le tableau en PNG..." + export_table_pdf: "Exporter le tableau en PDF..." view: "Affichage" show_grid: "Afficher la grille" tool_mode: "Mode outil" log_viewer: "Visionneuse de journaux" scatter_plots: "Nuages de points" histogram: "Histogramme" + lambda_delay: "Délai Lambda" + accel_enrich: "Enrichissement d'Accélération" side_panel: "Panneau lateral" files: "Fichiers" channels: "Canaux" @@ -170,6 +174,8 @@ toast: export_failed: "Echec de l'exportation : %{error}" histogram_exported_png: "Histogramme exporte en PNG" histogram_exported_pdf: "Histogramme exporte en PDF" + table_exported_png: "Tableau exporté en PNG (cellules seulement ; CSV pour les valeurs)" + table_exported_pdf: "Tableau exporté en PDF" scatter_exported_png: "Nuage de points exporte en PNG" scatter_exported_pdf: "Nuage de points exporte en PDF" failed_to_save: "Echec de l'enregistrement : %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Exploiter les événements enregistrés dans les tableaux d'enrichissement de retard lambda et d'accélération." load_file_hint: "Chargez un fichier pour générer des tableaux" status: "%{logs} journal(s), %{events} événements" - window_title: "Générateurs de Tableau" + configure_hint: "Associez les canaux dans le panneau Propriétés de l'outil, puis cliquez sur Exécuter." setup_header: "Configuration - %{file}" channels: "Canaux" auto_detect: "Détection automatique" @@ -401,6 +407,8 @@ tools: log_viewer: "Visionneuse de journaux" scatter_plots: "Nuages de points" histogram: "Histogramme" + lambda_delay: "Délai Lambda" + accel_enrich: "Enrichissement d'Accélération" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 644074a..4c1bb8a 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -9,12 +9,16 @@ menu: export_png: "PNG के रूप में निर्यात करें..." export_pdf: "PDF के रूप में निर्यात करें..." export_histogram_pdf: "हिस्टोग्राम PDF के रूप में निर्यात करें..." + export_table_png: "तालिका PNG के रूप में निर्यात करें..." + export_table_pdf: "तालिका PDF के रूप में निर्यात करें..." view: "दृश्य" show_grid: "ग्रिड दिखाएं" tool_mode: "टूल मोड" log_viewer: "लॉग व्यूअर" scatter_plots: "स्कैटर प्लॉट" histogram: "हिस्टोग्राम" + lambda_delay: "लैम्ब्डा विलंब" + accel_enrich: "त्वरण संवर्धन" side_panel: "साइड पैनल" files: "फ़ाइलें" channels: "चैनल" @@ -170,6 +174,8 @@ toast: export_failed: "निर्यात विफल: %{error}" histogram_exported_png: "हिस्टोग्राम PNG के रूप में निर्यात किया गया" histogram_exported_pdf: "हिस्टोग्राम PDF के रूप में निर्यात किया गया" + table_exported_png: "तालिका PNG के रूप में निर्यात हुई (केवल सेल; मानों के लिए CSV)" + table_exported_pdf: "तालिका PDF के रूप में निर्यात हुई" scatter_exported_png: "स्कैटर प्लॉट PNG के रूप में निर्यात किया गया" scatter_exported_pdf: "स्कैटर प्लॉट PDF के रूप में निर्यात किया गया" failed_to_save: "सहेजने में विफल: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "लॉग की गई घटनाओं को लैम्ब्डा विलंब और त्वरण संवर्धन तालिकाओं में खनन करें।" load_file_hint: "तालिकाएँ बनाने के लिए एक फ़ाइल लोड करें" status: "%{logs} लॉग, %{events} घटनाएँ" - window_title: "टेबल जेनरेटर" + configure_hint: "टूल प्रॉपर्टीज़ पैनल में चैनल मैप करें, फिर रन क्लिक करें।" setup_header: "सेटअप - %{file}" channels: "चैनल" auto_detect: "स्वचालित पहचान" @@ -401,6 +407,8 @@ tools: log_viewer: "लॉग व्यूअर" scatter_plots: "स्कैटर प्लॉट" histogram: "हिस्टोग्राम" + lambda_delay: "लैम्ब्डा विलंब" + accel_enrich: "त्वरण संवर्धन" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/id.yaml b/i18n/id.yaml index a2547e8..7e0d5dc 100644 --- a/i18n/id.yaml +++ b/i18n/id.yaml @@ -9,12 +9,16 @@ menu: export_png: "Ekspor sebagai PNG..." export_pdf: "Ekspor sebagai PDF..." export_histogram_pdf: "Ekspor Histogram sebagai PDF..." + export_table_png: "Ekspor Tabel sebagai PNG..." + export_table_pdf: "Ekspor Tabel sebagai PDF..." view: "Tampilan" show_grid: "Tampilkan Kisi" tool_mode: "Mode Alat" log_viewer: "Penampil Log" scatter_plots: "Diagram Sebar" histogram: "Histogram" + lambda_delay: "Tunda Lambda" + accel_enrich: "Pengayaan Akselerasi" side_panel: "Panel Samping" files: "Berkas" channels: "Kanal" @@ -170,6 +174,8 @@ toast: export_failed: "Ekspor gagal: %{error}" histogram_exported_png: "Histogram diekspor sebagai PNG" histogram_exported_pdf: "Histogram diekspor sebagai PDF" + table_exported_png: "Tabel diekspor sebagai PNG (sel saja; gunakan CSV untuk nilai)" + table_exported_pdf: "Tabel diekspor sebagai PDF" scatter_exported_png: "Diagram sebar diekspor sebagai PNG" scatter_exported_pdf: "Diagram sebar diekspor sebagai PDF" failed_to_save: "Gagal menyimpan: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Menambang peristiwa yang dicatat menjadi tabel pengayaan penundaan lambda dan akselerasi." load_file_hint: "Muat file untuk membuat tabel" status: "%{logs} log, %{events} peristiwa" - window_title: "Pembuat Tabel" + configure_hint: "Petakan kanal di panel Properti Alat, lalu klik Jalankan." setup_header: "Pengaturan - %{file}" channels: "Saluran" auto_detect: "Deteksi Otomatis" @@ -401,6 +407,8 @@ tools: log_viewer: "Penampil Log" scatter_plots: "Diagram Sebar" histogram: "Histogram" + lambda_delay: "Tunda Lambda" + accel_enrich: "Pengayaan Akselerasi" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/it.yaml b/i18n/it.yaml index c0357c5..b1fba6e 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -9,12 +9,16 @@ menu: export_png: "Esporta come PNG..." export_pdf: "Esporta come PDF..." export_histogram_pdf: "Esporta Istogramma come PDF..." + export_table_png: "Esporta Tabella come PNG..." + export_table_pdf: "Esporta Tabella come PDF..." view: "Visualizza" show_grid: "Mostra griglia" tool_mode: "Modalita' Strumento" log_viewer: "Visualizzatore Log" scatter_plots: "Grafici a Dispersione" histogram: "Istogramma" + lambda_delay: "Ritardo Lambda" + accel_enrich: "Arricchimento Accelerazione" side_panel: "Pannello Laterale" files: "File" channels: "Canali" @@ -170,6 +174,8 @@ toast: export_failed: "Esportazione fallita: %{error}" histogram_exported_png: "Istogramma esportato come PNG" histogram_exported_pdf: "Istogramma esportato come PDF" + table_exported_png: "Tabella esportata come PNG (solo celle; CSV per i valori)" + table_exported_pdf: "Tabella esportata come PDF" scatter_exported_png: "Grafico a dispersione esportato come PNG" scatter_exported_pdf: "Grafico a dispersione esportato come PDF" failed_to_save: "Salvataggio fallito: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Estrarre gli eventi registrati in tabelle di arricchimento del ritardo lambda e dell'accelerazione." load_file_hint: "Carica un file per generare tabelle" status: "%{logs} registro(i), %{events} eventi" - window_title: "Generatori di Tabelle" + configure_hint: "Mappa i canali nel pannello Proprietà Strumento, poi premi Esegui." setup_header: "Configurazione - %{file}" channels: "Canali" auto_detect: "Rilevamento automatico" @@ -401,6 +407,8 @@ tools: log_viewer: "Visualizzatore Log" scatter_plots: "Grafici a Dispersione" histogram: "Istogramma" + lambda_delay: "Ritardo Lambda" + accel_enrich: "Arricchimento Accelerazione" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/ja.yaml b/i18n/ja.yaml index 406215a..f820cf6 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -9,12 +9,16 @@ menu: export_png: "PNGとしてエクスポート..." export_pdf: "PDFとしてエクスポート..." export_histogram_pdf: "ヒストグラムをPDFでエクスポート..." + export_table_png: "テーブルをPNGでエクスポート..." + export_table_pdf: "テーブルをPDFでエクスポート..." view: "表示" show_grid: "グリッドを表示" tool_mode: "ツールモード" log_viewer: "ログビューア" scatter_plots: "散布図" histogram: "ヒストグラム" + lambda_delay: "ラムダ遅延" + accel_enrich: "加速増量" side_panel: "サイドパネル" files: "ファイル" channels: "チャンネル" @@ -170,6 +174,8 @@ toast: export_failed: "エクスポートに失敗しました: %{error}" histogram_exported_png: "ヒストグラムをPNGとしてエクスポートしました" histogram_exported_pdf: "ヒストグラムをPDFとしてエクスポートしました" + table_exported_png: "テーブルをPNGとしてエクスポートしました(セルのみ、数値はCSVを使用)" + table_exported_pdf: "テーブルをPDFとしてエクスポートしました" scatter_exported_png: "散布図をPNGとしてエクスポートしました" scatter_exported_pdf: "散布図をPDFとしてエクスポートしました" failed_to_save: "保存に失敗しました: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "ログされたイベントをラムダ遅延と加速エンリッチメントテーブルに分類します。" load_file_hint: "ファイルを読み込んでテーブルを生成します" status: "%{logs}個のログ、%{events}個のイベント" - window_title: "テーブルジェネレータ" + configure_hint: "ツールプロパティパネルでチャンネルを割り当て、実行をクリックしてください。" setup_header: "セットアップ - %{file}" channels: "チャンネル" auto_detect: "自動検出" @@ -401,6 +407,8 @@ tools: log_viewer: "ログビューア" scatter_plots: "散布図" histogram: "ヒストグラム" + lambda_delay: "ラムダ遅延" + accel_enrich: "加速増量" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/pt-BR.yaml b/i18n/pt-BR.yaml index 08a7c75..163d656 100644 --- a/i18n/pt-BR.yaml +++ b/i18n/pt-BR.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exportar como PNG..." export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." + export_table_png: "Exportar Tabela como PNG..." + export_table_pdf: "Exportar Tabela como PDF..." view: "Visualizar" show_grid: "Mostrar Grade" tool_mode: "Modo de Ferramenta" log_viewer: "Visualizador de Logs" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" side_panel: "Painel Lateral" files: "Arquivos" channels: "Canais" @@ -170,6 +174,8 @@ toast: export_failed: "Falha na exportação: %{error}" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" + table_exported_png: "Tabela exportada como PNG (apenas células; use CSV para valores)" + table_exported_pdf: "Tabela exportada como PDF" scatter_exported_png: "Gráfico de dispersão exportado como PNG" scatter_exported_pdf: "Gráfico de dispersão exportado como PDF" failed_to_save: "Falha ao salvar: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "Minerar eventos registrados em tabelas de enriquecimento de atraso lambda e aceleração." load_file_hint: "Carregue um arquivo para gerar tabelas" status: "%{logs} registro(s), %{events} eventos" - window_title: "Geradores de Tabela" + configure_hint: "Mapeie os canais no painel Propriedades da Ferramenta e clique em Executar." setup_header: "Configuração - %{file}" channels: "Canais" auto_detect: "Detecção automática" @@ -402,6 +408,8 @@ tools: log_viewer: "Visualizador de Logs" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" # Barra de atividades (src/ui/activity_bar.rs) activity: diff --git a/i18n/pt-PT.yaml b/i18n/pt-PT.yaml index 15c1f81..45c9cf4 100644 --- a/i18n/pt-PT.yaml +++ b/i18n/pt-PT.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exportar como PNG..." export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." + export_table_png: "Exportar Tabela como PNG..." + export_table_pdf: "Exportar Tabela como PDF..." view: "Ver" show_grid: "Mostrar Grelha" tool_mode: "Modo de Ferramenta" log_viewer: "Visualizador de Registos" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" side_panel: "Painel Lateral" files: "Ficheiros" channels: "Canais" @@ -170,6 +174,8 @@ toast: export_failed: "Falha na exportação: %{error}" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" + table_exported_png: "Tabela exportada como PNG (apenas células; use CSV para valores)" + table_exported_pdf: "Tabela exportada como PDF" scatter_exported_png: "Gráfico de dispersão exportado como PNG" scatter_exported_pdf: "Gráfico de dispersão exportado como PDF" failed_to_save: "Falha ao guardar: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Extrair eventos registados em tabelas de enriquecimento de atraso lambda e aceleração." load_file_hint: "Carregue um ficheiro para gerar tabelas" status: "%{logs} registo(s), %{events} eventos" - window_title: "Geradores de Tabela" + configure_hint: "Mapeie os canais no painel Propriedades da Ferramenta e clique em Executar." setup_header: "Configuração - %{file}" channels: "Canais" auto_detect: "Detecção automática" @@ -401,6 +407,8 @@ tools: log_viewer: "Visualizador de Registos" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/ru.yaml b/i18n/ru.yaml index 3a7792f..1d14629 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -9,12 +9,16 @@ menu: export_png: "Экспортировать в PNG..." export_pdf: "Экспортировать в PDF..." export_histogram_pdf: "Экспортировать гистограмму в PDF..." + export_table_png: "Экспортировать таблицу в PNG..." + export_table_pdf: "Экспортировать таблицу в PDF..." view: "Вид" show_grid: "Показывать сетку" tool_mode: "Режим инструмента" log_viewer: "Просмотр логов" scatter_plots: "Диаграммы рассеяния" histogram: "Гистограмма" + lambda_delay: "Задержка лямбда" + accel_enrich: "Обогащение при ускорении" side_panel: "Боковая панель" files: "Файлы" channels: "Каналы" @@ -170,6 +174,8 @@ toast: export_failed: "Ошибка экспорта: %{error}" histogram_exported_png: "Гистограмма экспортирована в PNG" histogram_exported_pdf: "Гистограмма экспортирована в PDF" + table_exported_png: "Таблица экспортирована в PNG (только ячейки; значения в CSV)" + table_exported_pdf: "Таблица экспортирована в PDF" scatter_exported_png: "Диаграмма рассеяния экспортирована в PNG" scatter_exported_pdf: "Диаграмма рассеяния экспортирована в PDF" failed_to_save: "Не удалось сохранить: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Извлечение записанных событий в таблицы задержки лямбда и обогащения ускорения." load_file_hint: "Загрузите файл для создания таблиц" status: "%{logs} журнал(ов), %{events} события" - window_title: "Генераторы таблиц" + configure_hint: "Назначьте каналы в панели Свойства инструмента и нажмите Запустить." setup_header: "Настройка - %{file}" channels: "Каналы" auto_detect: "Автоматическое определение" @@ -401,6 +407,8 @@ tools: log_viewer: "Просмотр логов" scatter_plots: "Диаграммы рассеяния" histogram: "Гистограмма" + lambda_delay: "Задержка лямбда" + accel_enrich: "Обогащение при ускорении" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/ur.yaml b/i18n/ur.yaml index 1581905..1cfc0ca 100644 --- a/i18n/ur.yaml +++ b/i18n/ur.yaml @@ -10,12 +10,16 @@ menu: export_png: "PNG کے طور پر برآمد کریں..." export_pdf: "PDF کے طور پر برآمد کریں..." export_histogram_pdf: "ہسٹوگرام PDF کے طور پر برآمد کریں..." + export_table_png: "ٹیبل PNG کے طور پر برآمد کریں..." + export_table_pdf: "ٹیبل PDF کے طور پر برآمد کریں..." view: "منظر" show_grid: "گرڈ دکھائیں" tool_mode: "ٹول موڈ" log_viewer: "لاگ ویور" scatter_plots: "سکیٹر پلاٹس" histogram: "ہسٹوگرام" + lambda_delay: "لیمبڈا تاخیر" + accel_enrich: "ایکسلریشن افزودگی" side_panel: "سائیڈ پینل" files: "فائلیں" channels: "چینلز" @@ -171,6 +175,8 @@ toast: export_failed: "برآمد ناکام: %{error}" histogram_exported_png: "ہسٹوگرام PNG کے طور پر برآمد ہوگیا" histogram_exported_pdf: "ہسٹوگرام PDF کے طور پر برآمد ہوگیا" + table_exported_png: "ٹیبل PNG کے طور پر برآمد ہوا (صرف خلیے؛ اقدار کے لیے CSV)" + table_exported_pdf: "ٹیبل PDF کے طور پر برآمد ہوا" scatter_exported_png: "سکیٹر پلاٹ PNG کے طور پر برآمد ہوگیا" scatter_exported_pdf: "سکیٹر پلاٹ PDF کے طور پر برآمد ہوگیا" failed_to_save: "محفوظ کرنے میں ناکامی: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "لاگ شدہ واقعات کو lambda تاخیر اور سرعت میں اضافے کی میزیں معدن سے نکالیں۔" load_file_hint: "ٹیبل تیار کرنے کے لیے فائل لوڈ کریں" status: "%{logs} لاگ، %{events} واقعات" - window_title: "ٹیبل جنریٹرز" + configure_hint: "ٹول پراپرٹیز پینل میں چینلز میپ کریں، پھر رن دبائیں۔" setup_header: "سیٹ اپ - %{file}" channels: "چینلز" auto_detect: "خودکار دریافت" @@ -402,6 +408,8 @@ tools: log_viewer: "لاگ ویور" scatter_plots: "سکیٹر پلاٹس" histogram: "ہسٹوگرام" + lambda_delay: "لیمبڈا تاخیر" + accel_enrich: "ایکسلریشن افزودگی" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index efd5e2a..6e49015 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -9,12 +9,16 @@ menu: export_png: "导出为 PNG..." export_pdf: "导出为 PDF..." export_histogram_pdf: "导出直方图为 PDF..." + export_table_png: "导出表格为 PNG..." + export_table_pdf: "导出表格为 PDF..." view: "视图" show_grid: "显示网格" tool_mode: "工具模式" log_viewer: "日志查看器" scatter_plots: "散点图" histogram: "直方图" + lambda_delay: "Lambda 延迟" + accel_enrich: "加速加浓" side_panel: "侧边栏" files: "文件" channels: "通道" @@ -170,6 +174,8 @@ toast: export_failed: "导出失败: %{error}" histogram_exported_png: "直方图已导出为 PNG" histogram_exported_pdf: "直方图已导出为 PDF" + table_exported_png: "表格已导出为 PNG(仅单元格;数值请使用 CSV)" + table_exported_pdf: "表格已导出为 PDF" scatter_exported_png: "散点图已导出为 PNG" scatter_exported_pdf: "散点图已导出为 PDF" failed_to_save: "保存失败: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "将记录的事件挖掘到λ延迟和加速浓缩表中。" load_file_hint: "加载文件以生成表格" status: "%{logs}个日志,%{events}个事件" - window_title: "表格生成器" + configure_hint: "在工具属性面板中映射通道,然后点击运行。" setup_header: "设置 - %{file}" channels: "通道" auto_detect: "自动检测" @@ -402,6 +408,8 @@ tools: log_viewer: "日志查看器" scatter_plots: "散点图" histogram: "直方图" + lambda_delay: "Lambda 延迟" + accel_enrich: "加速加浓" # 活动栏 (src/ui/activity_bar.rs) activity: diff --git a/src/analysis/tables/export.rs b/src/analysis/tables/export.rs index f5fa160..089e0a5 100644 --- a/src/analysis/tables/export.rs +++ b/src/analysis/tables/export.rs @@ -79,7 +79,8 @@ fn fmt_number(v: f64, decimals: usize) -> String { format!("{v:.decimals$}") } -fn fmt_edge(v: f64) -> String { +/// Axis edge label: integers print bare, otherwise two decimals. +pub fn fmt_edge(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { @@ -98,7 +99,7 @@ fn header_row(grid: &TableGrid, sep: &str) -> String { } /// Value of one cell after options: `None` renders blank. -fn cell_value( +pub fn cell_value( grid: &TableGrid, row: usize, col: usize, diff --git a/src/app.rs b/src/app.rs index 81fe571..49f460f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1608,6 +1608,18 @@ impl UltraLogApp { } } + /// Switch the active tool. The single entry point for the tool switcher, + /// menu radios, and Cmd+1..5, so analytics and table-tool selection + /// reset stay in one place. + pub fn set_active_tool(&mut self, tool: ActiveTool) { + if self.active_tool == tool { + return; + } + self.active_tool = tool; + self.table_generator.clear_selection(); + analytics::track_tool_switched(tool.name()); + } + /// Get the cursor time for the active tab pub fn get_cursor_time(&self) -> Option { self.active_tab.and_then(|idx| self.tabs[idx].cursor_time) @@ -2032,19 +2044,20 @@ impl UltraLogApp { return; } - // ⌘1/2/3 - Switch tool modes + // ⌘1..5 - Switch tool modes (same order as ActiveTool::ALL) 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; + const KEYS: [egui::Key; 5] = [ + egui::Key::Num1, + egui::Key::Num2, + egui::Key::Num3, + egui::Key::Num4, + egui::Key::Num5, + ]; + for (key, tool) in KEYS.into_iter().zip(ActiveTool::ALL) { + if i.key_pressed(key) { + self.set_active_tool(tool); + return; + } } } @@ -2141,6 +2154,7 @@ impl UltraLogApp { ActiveTool::LogViewer => self.export_chart_png(), ActiveTool::ScatterPlot => self.export_scatter_plot_png(), ActiveTool::Histogram => self.export_histogram_png(), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => self.export_table_png(), } return; } @@ -2290,7 +2304,6 @@ impl eframe::App for UltraLogApp { self.render_computed_channels_manager(ctx); self.render_formula_editor(ctx); self.render_analysis_panel(ctx); - self.render_table_generator(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -2355,8 +2368,9 @@ impl eframe::App for UltraLogApp { }); // 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; + let show_timeline = self.get_time_range().is_some() + && self.active_tool != ActiveTool::ScatterPlot + && self.active_tool.generator_kind().is_none(); if show_timeline { egui::Panel::bottom("timeline_panel") @@ -2433,6 +2447,10 @@ impl eframe::App for UltraLogApp { ui.add_space(10.0); self.render_histogram_view(ui); } + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + ui.add_space(10.0); + self.render_table_tool_view(ui); + } } }); } diff --git a/src/ipc/handler.rs b/src/ipc/handler.rs index eed3497..afca6b1 100644 --- a/src/ipc/handler.rs +++ b/src/ipc/handler.rs @@ -144,6 +144,8 @@ impl UltraLogApp { ActiveTool::LogViewer => "chart".to_string(), ActiveTool::ScatterPlot => "scatter".to_string(), ActiveTool::Histogram => "histogram".to_string(), + ActiveTool::LambdaDelay => "lambda_delay".to_string(), + ActiveTool::AccelEnrich => "accel_enrich".to_string(), }, }; @@ -829,7 +831,7 @@ impl UltraLogApp { .position(|c| c.name().eq_ignore_ascii_case(y_channel)); // Switch to scatter plot view - self.active_tool = ActiveTool::ScatterPlot; + self.set_active_tool(ActiveTool::ScatterPlot); // Configure the scatter plot (now we can get mutable borrow) if let Some(state) = self.get_scatter_plot_state_mut() @@ -843,7 +845,7 @@ impl UltraLogApp { } fn handle_show_chart(&mut self) -> IpcResponse { - self.active_tool = ActiveTool::LogViewer; + self.set_active_tool(ActiveTool::LogViewer); IpcResponse::ok() } diff --git a/src/state.rs b/src/state.rs index f9a6ce1..d0a3d2a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; +use crate::analysis::tables::GeneratorKind; use crate::colormap::Colormap; use crate::laps::{GpsCoordSpec, LapInfo}; use crate::parsers::{Channel, EcuType, Log}; @@ -270,15 +271,51 @@ pub enum ActiveTool { ScatterPlot, /// Histogram view for 2D distribution analysis Histogram, + /// Lambda delay table generator (RPM x load) + LambdaDelay, + /// Acceleration enrichment table generator (RPM x TPS rate) + AccelEnrich, } impl ActiveTool { + /// Every tool, in tool-switcher / menu / shortcut order (Cmd+1..5). + pub const ALL: [ActiveTool; 5] = [ + ActiveTool::LogViewer, + ActiveTool::ScatterPlot, + ActiveTool::Histogram, + ActiveTool::LambdaDelay, + ActiveTool::AccelEnrich, + ]; + /// Get the display name for this tool pub fn name(&self) -> &'static str { match self { ActiveTool::LogViewer => "Log Viewer", ActiveTool::ScatterPlot => "Scatter Plots", ActiveTool::Histogram => "Histogram", + ActiveTool::LambdaDelay => "Lambda Delay", + ActiveTool::AccelEnrich => "Accel Enrichment", + } + } + + /// The table generator behind this tool, if it is one. + /// + /// Table tools share one code path (`src/ui/table_generator.rs`), so the + /// match sites that only care whether the active tool is a table use this + /// instead of listing both variants. + pub fn generator_kind(self) -> Option { + match self { + ActiveTool::LambdaDelay => Some(GeneratorKind::LambdaDelay), + ActiveTool::AccelEnrich => Some(GeneratorKind::AccelEnrich), + _ => None, + } + } + + /// The tool that hosts a table generator. + pub fn for_generator(kind: GeneratorKind) -> ActiveTool { + match kind { + GeneratorKind::LambdaDelay => ActiveTool::LambdaDelay, + GeneratorKind::AccelEnrich => ActiveTool::AccelEnrich, } } } diff --git a/src/ui/export.rs b/src/ui/export.rs index a5250aa..174599b 100644 --- a/src/ui/export.rs +++ b/src/ui/export.rs @@ -12,7 +12,14 @@ use crate::normalize::normalize_channel_name_with_custom; use crate::state::HistogramMode; /// Helper to push text ops into a Vec -fn push_text(ops: &mut Vec, text: &str, size: f32, x: Mm, y: Mm, font: &PdfFontHandle) { +pub(crate) fn push_text( + ops: &mut Vec, + text: &str, + size: f32, + x: Mm, + y: Mm, + font: &PdfFontHandle, +) { ops.push(Op::StartTextSection); ops.push(Op::SetFont { font: font.clone(), @@ -28,7 +35,7 @@ fn push_text(ops: &mut Vec, text: &str, size: f32, x: Mm, y: Mm, font: &PdfF } /// Helper to push a filled rectangle (polygon) into ops -fn push_filled_rect(ops: &mut Vec, x: f32, y: f32, w: f32, h: f32) { +pub(crate) fn push_filled_rect(ops: &mut Vec, x: f32, y: f32, w: f32, h: f32) { let rect = Polygon { rings: vec![PolygonRing { points: vec![ @@ -57,7 +64,7 @@ fn push_filled_rect(ops: &mut Vec, x: f32, y: f32, w: f32, h: f32) { } /// Helper to push a closed line (border) into ops -fn push_closed_line(ops: &mut Vec, points: &[(f32, f32)]) { +pub(crate) fn push_closed_line(ops: &mut Vec, points: &[(f32, f32)]) { let line = Line { points: points .iter() @@ -1592,7 +1599,7 @@ impl UltraLogApp { } /// Draw a line between two points using Bresenham's algorithm -fn draw_line(img: &mut RgbaImage, x0: u32, y0: u32, x1: u32, y1: u32, color: Rgba) { +pub(crate) fn draw_line(img: &mut RgbaImage, x0: u32, y0: u32, x1: u32, y1: u32, color: Rgba) { let dx = (x1 as i32 - x0 as i32).abs(); let dy = -(y1 as i32 - y0 as i32).abs(); let sx: i32 = if x0 < x1 { 1 } else { -1 }; diff --git a/src/ui/menu.rs b/src/ui/menu.rs index e69ac55..ea09ba3 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -79,7 +79,12 @@ impl UltraLogApp { config.x_channel.is_some() && config.y_channel.is_some() }; - let can_export = has_chart_data || has_histogram_data; + let has_table_data = self + .active_tool + .generator_kind() + .is_some_and(|k| self.table_generator.table(k).is_some()); + + let can_export = has_chart_data || has_histogram_data || has_table_data; ui.add_enabled_ui(can_export, |ui| { ui.menu_button(t!("menu.export"), |ui| { @@ -87,7 +92,16 @@ impl UltraLogApp { .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if self.active_tool == ActiveTool::Histogram && has_histogram_data { + if has_table_data { + if ui.button(t!("menu.export_table_png")).clicked() { + self.export_table_png(); + ui.close(); + } + if ui.button(t!("menu.export_table_pdf")).clicked() { + self.export_table_pdf(); + ui.close(); + } + } else if self.active_tool == ActiveTool::Histogram && has_histogram_data { if ui.button(t!("menu.export_histogram_pdf")).clicked() { self.export_histogram_pdf(); ui.close(); @@ -139,38 +153,22 @@ impl UltraLogApp { .color(egui::Color32::GRAY), ); - if ui - .radio_value( - &mut self.active_tool, - ActiveTool::LogViewer, - t!("menu.log_viewer"), - ) - .on_hover_text("\u{2318}1") - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.active_tool, - ActiveTool::ScatterPlot, - t!("menu.scatter_plots"), - ) - .on_hover_text("\u{2318}2") - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.active_tool, - ActiveTool::Histogram, - t!("menu.histogram"), - ) - .on_hover_text("\u{2318}3") - .clicked() - { - ui.close(); + for (i, tool) in ActiveTool::ALL.into_iter().enumerate() { + let label = match tool { + ActiveTool::LogViewer => t!("menu.log_viewer"), + ActiveTool::ScatterPlot => t!("menu.scatter_plots"), + ActiveTool::Histogram => t!("menu.histogram"), + ActiveTool::LambdaDelay => t!("menu.lambda_delay"), + ActiveTool::AccelEnrich => t!("menu.accel_enrich"), + }; + if ui + .radio(self.active_tool == tool, label.as_ref()) + .on_hover_text(format!("\u{2318}{}", i + 1)) + .clicked() + { + self.set_active_tool(tool); + ui.close(); + } } ui.separator(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2d9f65a..7f3d859 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -53,6 +53,7 @@ pub mod normalization_editor; pub mod scatter_plot; pub mod sidebar; pub mod tab_bar; +pub mod table_export; pub mod table_generator; pub mod timeline; pub mod toast; diff --git a/src/ui/table_export.rs b/src/ui/table_export.rs new file mode 100644 index 0000000..f72f277 --- /dev/null +++ b/src/ui/table_export.rs @@ -0,0 +1,628 @@ +//! PNG and PDF export for generated tuning tables (lambda delay, accel +//! enrichment). +//! +//! The renderers are free functions over a [`TableAccumulator`] so they are +//! unit-testable without an `UltraLogApp`. Cell values, blanking of +//! low-confidence cells, and delay-unit conversion all go through +//! `analysis::tables::export::cell_value`, the same path CSV export uses, so +//! the four exports never disagree. +//! +//! PDF draws values and axis labels with Helvetica. PNG follows the histogram +//! precedent: this crate has no font rasterizer, so PNG is cells and grid +//! lines only. CSV and clipboard remain the numeric exports. + +use std::path::Path; + +use ::image::{Rgba, RgbaImage}; +use printpdf::*; +use rust_i18n::t; + +use crate::analysis::tables::binning::Confidence; +use crate::analysis::tables::export::{ExportOptions, cell_value, fmt_edge}; +use crate::analysis::tables::{GeneratorKind, TableAccumulator}; +use crate::analytics; +use crate::app::UltraLogApp; +use crate::colormap::{Colormap, sample}; +use crate::ui::export::{draw_line, push_closed_line, push_filled_rect, push_text}; + +/// Colour for a cell's normalized value. +fn cell_rgb(t: f64) -> [u8; 3] { + let c = sample(Colormap::Viridis, t as f32); + [c.r(), c.g(), c.b()] +} + +/// Black or white text for a Viridis background (WCAG relative luminance). +fn text_on(rgb: [u8; 3]) -> bool { + let lin = |c: u8| { + let v = c as f64 / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + let lum = 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); + lum > 0.4 +} + +/// `(value, decimals)` per cell after export options; `None` renders blank. +type ResolvedCells = Vec>>; + +/// Resolve every cell to `(value, decimals)` after export options, plus the +/// value range used for colouring. +fn resolve_cells( + acc: &TableAccumulator, + measure_idx: usize, + opts: &ExportOptions, +) -> Result<(ResolvedCells, f64, f64), Box> { + let measure = acc + .measures + .get(measure_idx) + .ok_or("Table has no measures")?; + let grid = acc.grid(measure_idx); + if grid.rows() == 0 || grid.cols() == 0 { + return Err("Table has no cells".into()); + } + let delay_table = acc.generator == GeneratorKind::LambdaDelay; + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let cells: ResolvedCells = (0..grid.rows()) + .map(|r| { + (0..grid.cols()) + .map(|c| { + let v = cell_value(&grid, r, c, measure, opts, delay_table); + if let Some((x, _)) = v { + lo = lo.min(x); + hi = hi.max(x); + } + v + }) + .collect() + }) + .collect(); + if !lo.is_finite() { + lo = 0.0; + hi = 1.0; + } + Ok((cells, lo, hi)) +} + +fn normalize(v: f64, lo: f64, hi: f64) -> f64 { + if (hi - lo).abs() < f64::EPSILON { + 0.5 + } else { + ((v - lo) / (hi - lo)).clamp(0.0, 1.0) + } +} + +/// Write the table as a 1920x1080 PNG: Viridis cells, grid lines, no text. +pub fn render_table_png( + acc: &TableAccumulator, + measure_idx: usize, + opts: &ExportOptions, + path: &Path, +) -> Result<(), Box> { + let (cells, lo, hi) = resolve_cells(acc, measure_idx, opts)?; + let grid = acc.grid(measure_idx); + let (rows, cols) = (grid.rows(), grid.cols()); + + let width = 1920u32; + let height = 1080u32; + let margin = 80u32; + let left = margin; + let right = width - margin; + let top = margin; + let bottom = height - margin; + let cell_w = (right - left) as f64 / cols as f64; + let cell_h = (bottom - top) as f64 / rows as f64; + + let mut img = RgbaImage::new(width, height); + for px in img.pixels_mut() { + *px = Rgba([30, 30, 30, 255]); + } + + for (r, row) in cells.iter().enumerate() { + // Highest Y bin at the top, the way ECU tables are laid out. + let draw_row = rows - 1 - r; + let y0 = top as f64 + draw_row as f64 * cell_h; + for (c, cell) in row.iter().enumerate() { + let x0 = left as f64 + c as f64 * cell_w; + let rgb = match cell { + Some((v, _)) => cell_rgb(normalize(*v, lo, hi)), + None => [36, 36, 36], + }; + let px = Rgba([rgb[0], rgb[1], rgb[2], 255]); + let x_end = (x0 + cell_w).min(right as f64) as u32; + let y_end = (y0 + cell_h).min(bottom as f64) as u32; + for y in y0 as u32..y_end { + for x in x0 as u32..x_end { + img.put_pixel(x, y, px); + } + } + } + } + + let line = Rgba([60, 60, 60, 255]); + for c in 0..=cols { + let x = (left as f64 + c as f64 * cell_w).min(right as f64) as u32; + draw_line(&mut img, x, top, x, bottom, line); + } + for r in 0..=rows { + let y = (top as f64 + r as f64 * cell_h).min(bottom as f64) as u32; + draw_line(&mut img, left, y, right, y, line); + } + let border = Rgba([120, 120, 120, 255]); + draw_line(&mut img, left, top, right, top, border); + draw_line(&mut img, left, bottom, right, bottom, border); + draw_line(&mut img, left, top, left, bottom, border); + draw_line(&mut img, right, top, right, bottom, border); + + img.save(path)?; + Ok(()) +} + +/// Write the table as an A4-landscape PDF with values and axis labels. +pub fn render_table_pdf( + acc: &TableAccumulator, + measure_idx: usize, + opts: &ExportOptions, + title: &str, + generated: &str, + path: &Path, +) -> Result<(), Box> { + let (cells, lo, hi) = resolve_cells(acc, measure_idx, opts)?; + let grid = acc.grid(measure_idx); + let (rows, cols) = (grid.rows(), grid.cols()); + let measure = &acc.measures[measure_idx]; + + let font_bold = PdfFontHandle::Builtin(BuiltinFont::HelveticaBold); + let font_regular = PdfFontHandle::Builtin(BuiltinFont::Helvetica); + let mut ops: Vec = Vec::new(); + + // A4 landscape, mm. + let margin: f64 = 15.0; + let label_w: f64 = 22.0; + let label_h: f64 = 8.0; + let chart_left = margin + label_w; + let chart_right: f64 = 297.0 - margin; + let chart_top: f64 = 210.0 - margin - 26.0; + let chart_bottom = margin + label_h + 6.0; + let cell_w = (chart_right - chart_left) / cols as f64; + let cell_h = (chart_top - chart_bottom) / rows as f64; + + let black = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); + let white = Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)); + + ops.push(Op::SetFillColor { col: black.clone() }); + push_text( + &mut ops, + title, + 16.0, + Mm(margin as f32), + Mm(200.0), + &font_bold, + ); + let unit = if acc.generator == GeneratorKind::LambdaDelay && measure.unit == "ms" { + opts.delay_unit.label().to_string() + } else { + measure.unit.to_string() + }; + let logs: Vec<&str> = acc.logs.iter().map(|l| l.name.as_str()).collect(); + let subtitle = format!( + "{} ({}) | {} | Logs: {}", + measure.label, + unit, + generated, + logs.join(", ") + ); + push_text( + &mut ops, + &subtitle, + 9.0, + Mm(margin as f32), + Mm(193.0), + &font_regular, + ); + let (filled, high) = grid.coverage(); + let coverage = format!( + "Rows: {} | Columns: {} | {}/{} cells filled, {} high confidence{}", + grid.y_axis.header(), + grid.x_axis.header(), + filled, + rows * cols, + high, + if opts.exclude_low { + " | low-confidence cells blank" + } else { + "" + } + ); + push_text( + &mut ops, + &coverage, + 8.0, + Mm(margin as f32), + Mm(188.0), + &font_regular, + ); + + // Cells. + let value_size = (cell_h as f32 * 1.6).clamp(5.0, 11.0); + let count_size = (value_size * 0.6).max(4.0); + for (r, row) in cells.iter().enumerate() { + let y = chart_bottom + r as f64 * cell_h; + for (c, cell) in row.iter().enumerate() { + let x = chart_left + c as f64 * cell_w; + let Some((v, decimals)) = cell else { + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb::new(0.93, 0.93, 0.93, None)), + }); + push_filled_rect(&mut ops, x as f32, y as f32, cell_w as f32, cell_h as f32); + continue; + }; + let rgb = cell_rgb(normalize(*v, lo, hi)); + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb::new( + rgb[0] as f32 / 255.0, + rgb[1] as f32 / 255.0, + rgb[2] as f32 / 255.0, + None, + )), + }); + push_filled_rect(&mut ops, x as f32, y as f32, cell_w as f32, cell_h as f32); + + let fg = if text_on(rgb) { + black.clone() + } else { + white.clone() + }; + ops.push(Op::SetFillColor { col: fg }); + let text = format!("{v:.decimals$}"); + // Helvetica averages ~0.5em per glyph; centre approximately. + let text_w_mm = text.len() as f64 * value_size as f64 * 0.5 * 0.3528; + push_text( + &mut ops, + &text, + value_size, + Mm((x + (cell_w - text_w_mm) / 2.0).max(x + 0.5) as f32), + Mm((y + cell_h / 2.0 - value_size as f64 * 0.12) as f32), + &font_regular, + ); + if let Some(stats) = grid.cell(r, c) { + // Built-in Helvetica maps `~` to an arrow glyph, so the + // markers stay in plain ASCII: `*` medium, `?` low. + let mark = match stats.confidence { + Confidence::High | Confidence::Empty => "", + Confidence::Medium => "*", + Confidence::Low => "?", + }; + push_text( + &mut ops, + &format!("{}{}", stats.count, mark), + count_size, + Mm((x + 0.6) as f32), + Mm((y + cell_h - count_size as f64 * 0.42) as f32), + &font_regular, + ); + } + } + } + + // Grid lines. + ops.push(Op::SetOutlineColor { + col: Color::Rgb(Rgb::new(0.5, 0.5, 0.5, None)), + }); + ops.push(Op::SetOutlineThickness { pt: Pt(0.25) }); + for c in 0..=cols { + let x = chart_left + c as f64 * cell_w; + push_closed_line( + &mut ops, + &[ + (x as f32, chart_bottom as f32), + (x as f32, chart_top as f32), + ], + ); + } + for r in 0..=rows { + let y = chart_bottom + r as f64 * cell_h; + push_closed_line( + &mut ops, + &[ + (chart_left as f32, y as f32), + (chart_right as f32, y as f32), + ], + ); + } + + // Axis labels: lower edges, X along the bottom, Y down the left. + ops.push(Op::SetFillColor { col: black }); + let axis_size = (cell_w as f32 * 0.9).clamp(5.0, 8.0); + for c in 0..cols { + let x = chart_left + c as f64 * cell_w; + push_text( + &mut ops, + &fmt_edge(grid.x_axis.lower_edge(c)), + axis_size, + Mm((x + 0.6) as f32), + Mm((chart_bottom - 4.0) as f32), + &font_regular, + ); + } + for r in 0..rows { + let y = chart_bottom + r as f64 * cell_h; + push_text( + &mut ops, + &fmt_edge(grid.y_axis.lower_edge(r)), + axis_size, + Mm(margin as f32), + Mm((y + cell_h / 2.0 - 1.0) as f32), + &font_regular, + ); + } + push_text( + &mut ops, + &grid.x_axis.header(), + 8.0, + Mm(chart_left as f32), + Mm((chart_bottom - 10.0) as f32), + &font_bold, + ); + push_text( + &mut ops, + &grid.y_axis.header(), + 8.0, + Mm(margin as f32), + Mm((chart_top + 2.0) as f32), + &font_bold, + ); + + let page = PdfPage::new(Mm(297.0), Mm(210.0), ops); + let mut doc = PdfDocument::new(title); + doc.with_pages(vec![page]); + let mut warnings = Vec::new(); + let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings); + std::fs::write(path, bytes)?; + Ok(()) +} + +impl UltraLogApp { + /// Export the active table tool's table as PNG. + pub fn export_table_png(&mut self) { + let Some(kind) = self.active_tool.generator_kind() else { + return; + }; + if self.table_generator.table(kind).is_none() { + self.show_toast_error(&t!("table_gen.no_events")); + return; + } + let Some(path) = rfd::FileDialog::new() + .add_filter("PNG Image", &["png"]) + .set_file_name(format!("ultralog_{}.png", kind.id())) + .save_file() + else { + return; + }; + let result = { + let Some(acc) = self.table_generator.table(kind) else { + return; + }; + let measure_idx = self.table_generator.measure_index(kind); + render_table_png(acc, measure_idx, &self.table_generator.export, &path) + }; + match result { + Ok(()) => { + analytics::track_export(&format!("{}_png", kind.id())); + self.show_toast_success(&t!("toast.table_exported_png")); + } + Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), + } + } + + /// Export the active table tool's table as PDF. + pub fn export_table_pdf(&mut self) { + let Some(kind) = self.active_tool.generator_kind() else { + return; + }; + if self.table_generator.table(kind).is_none() { + self.show_toast_error(&t!("table_gen.no_events")); + return; + } + let Some(path) = rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name(format!("ultralog_{}.pdf", kind.id())) + .save_file() + else { + return; + }; + let result = { + let Some(acc) = self.table_generator.table(kind) else { + return; + }; + let measure_idx = self.table_generator.measure_index(kind); + let title = format!("UltraLog {}", self.active_tool.name()); + let generated = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); + render_table_pdf( + acc, + measure_idx, + &self.table_generator.export, + &title, + &generated, + &path, + ) + }; + match result { + Ok(()) => { + analytics::track_export(&format!("{}_pdf", kind.id())); + self.show_toast_success(&t!("toast.table_exported_pdf")); + } + Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::tables::export::DelayUnit; + use crate::analysis::tables::{AxisSpec, MeasureSpec, RunReport, TableEvent}; + + fn synthetic_accumulator() -> TableAccumulator { + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0, 3000.0, 4000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0, 70.0]); + let measures = vec![MeasureSpec { + key: "dead_time", + label: "Dead time", + unit: "ms", + decimals: 0, + }]; + let mut acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), measures); + let events: Vec = (0..12) + .map(|i| TableEvent { + log_id: 1, + log_name: "synthetic.csv".into(), + time: i as f64, + rpm: 1500.0 + (i % 3) as f64 * 1000.0, + axis_value: 40.0 + (i % 2) as f64 * 20.0, + values: vec![100.0 + i as f64 * 5.0], + quality: 1.0, + reject: None, + note: String::new(), + }) + .collect(); + let report = RunReport { + log_name: "synthetic.csv".into(), + candidates: 12, + accepted: 12, + ..Default::default() + }; + acc.add_log(1, "synthetic.csv", events, report); + acc + } + + #[test] + fn png_writes_a_valid_image() { + let acc = synthetic_accumulator(); + let dir = std::env::temp_dir().join(format!("ultralog_table_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("table.png"); + render_table_png(&acc, 0, &ExportOptions::default(), &path).unwrap(); + let img = ::image::open(&path).unwrap(); + assert_eq!(img.width(), 1920); + assert_eq!(img.height(), 1080); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn pdf_writes_a_document_with_values() { + let acc = synthetic_accumulator(); + let dir = std::env::temp_dir().join(format!("ultralog_table_pdf_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("table.pdf"); + render_table_pdf( + &acc, + 0, + &ExportOptions::default(), + "UltraLog Lambda Delay", + "2026-09-18 10:00", + &path, + ) + .unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert!(bytes.len() > 1000); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn png_draws_the_highest_row_at_the_top() { + // Row 0 (MAP 30-50) holds every event's low value; row 1 is empty. + // On screen the highest Y bin is at the top, so the PNG must put + // row 0's Viridis fill in the bottom band and dark grey in the top. + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0, 70.0]); + let measures = vec![MeasureSpec { + key: "dead_time", + label: "Dead time", + unit: "ms", + decimals: 0, + }]; + let mut acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), measures); + let events: Vec = (0..10) + .map(|i| TableEvent { + log_id: 1, + log_name: "s".into(), + time: i as f64, + rpm: 1500.0, + axis_value: 40.0, + values: vec![100.0], + quality: 1.0, + reject: None, + note: String::new(), + }) + .collect(); + acc.add_log(1, "s", events, RunReport::default()); + let dir = std::env::temp_dir().join(format!("ultralog_row_order_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rows.png"); + render_table_png(&acc, 0, &ExportOptions::default(), &path).unwrap(); + let img = ::image::open(&path).unwrap().to_rgba8(); + let top = img.get_pixel(960, 80 + 200); + let bottom = img.get_pixel(960, 1080 - 80 - 200); + assert_eq!(top.0, [36, 36, 36, 255], "empty row 1 must be at the top"); + assert_ne!( + bottom.0, + [36, 36, 36, 255], + "filled row 0 must be at the bottom" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn resolve_cells_honours_export_options() { + let acc = synthetic_accumulator(); + // Every cell in the fixture has 2 samples -> Low, so blanking low + // cells empties the table and the colour range falls back to 0..1. + let blank = ExportOptions { + exclude_low: true, + ..ExportOptions::default() + }; + let (cells, lo, hi) = resolve_cells(&acc, 0, &blank).unwrap(); + assert!(cells.iter().flatten().all(Option::is_none)); + assert_eq!((lo, hi), (0.0, 1.0)); + + // Unit conversion goes through the same path CSV uses. + let ms = ExportOptions { + exclude_low: false, + ..ExportOptions::default() + }; + let cycles = ExportOptions { + exclude_low: false, + delay_unit: DelayUnit::EngineCycles, + ..ExportOptions::default() + }; + let (a, _, _) = resolve_cells(&acc, 0, &ms).unwrap(); + let (b, _, _) = resolve_cells(&acc, 0, &cycles).unwrap(); + let (va, _) = a[0][0].unwrap(); + let (vb, _) = b[0][0].unwrap(); + // 1000-2000 rpm cell centre is 1500 rpm: cycles = ms * rpm / 120000. + assert!((vb - va * 1500.0 / 120_000.0).abs() < 1e-9); + + // A measure index past the list is an error, not a panic. + assert!(resolve_cells(&acc, 5, &ms).is_err()); + } + + #[test] + fn empty_table_is_an_error_not_a_panic() { + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0]); + let acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), vec![]); + let path = std::env::temp_dir().join("ultralog_never_written.png"); + assert!(render_table_png(&acc, 0, &ExportOptions::default(), &path).is_err()); + } + + #[test] + fn text_colour_flips_on_bright_viridis() { + assert!(!text_on(cell_rgb(0.0))); + assert!(text_on(cell_rgb(1.0))); + } +} diff --git a/src/ui/table_generator.rs b/src/ui/table_generator.rs index f872480..0895e5e 100644 --- a/src/ui/table_generator.rs +++ b/src/ui/table_generator.rs @@ -1,14 +1,20 @@ -//! Table generator window: lambda delay (#4) and acceleration enrichment (#3) +//! Table generator tools: lambda delay (#4) and acceleration enrichment (#3) //! tables mined from the loaded logs. //! -//! Three states, like the analysis panel: -//! 1. **Setup** - channel roles (auto-suggested, ⚠ on ambiguity), axes, -//! parameters, and *Run on current file*. -//! 2. **Results** - Viridis heatmap with value text and a count badge coloured -//! by confidence; hover for the cell tooltip; click for the event inspector -//! with jump-to-time; toolbar for adding / removing logs, measure, export. -//! 3. **Empty** - the run report with its rejection breakdown, so threshold -//! tuning is guided rather than guesswork. +//! Each generator is an `ActiveTool` (`ActiveTool::LambdaDelay`, +//! `ActiveTool::AccelEnrich`), split the way Histogram is: +//! +//! - **Tool Properties panel** (`render_table_tool_properties`) - channel roles +//! (auto-suggested, ⚠ on ambiguity), load axis, axes, parameters, and +//! *Run / Add current file*. +//! - **Central panel** (`render_table_tool_view`) - Viridis heatmap with value +//! text and a count badge coloured by confidence; hover for the cell tooltip; +//! click for the event inspector with jump-to-time; toolbar for removing +//! logs, measure, unit, export. With no accepted events it shows the run +//! report's rejection breakdown so threshold tuning is guided. +//! +//! State is **app-level, not per-tab**: the accumulators are multi-log by +//! design, so switching tabs only changes which file *Run* reads. use std::collections::HashMap; @@ -42,17 +48,14 @@ struct MappingState { /// All table-generator window state, held on `UltraLogApp`. pub struct TableGeneratorState { - pub open: bool, - pub kind: GeneratorKind, generators: HashMap>, mappings: HashMap, pub accumulators: HashMap, last_report: HashMap, - last_error: Option, + last_error: HashMap, measure: HashMap, selected_cell: Option<(usize, usize)>, - export: ExportOptions, - show_setup: bool, + pub export: ExportOptions, show_warnings: bool, } @@ -63,30 +66,20 @@ impl Default for TableGeneratorState { .map(|k| (k, k.create())) .collect(); Self { - open: false, - kind: GeneratorKind::default(), generators, mappings: HashMap::new(), accumulators: HashMap::new(), last_report: HashMap::new(), - last_error: None, + last_error: HashMap::new(), measure: HashMap::new(), selected_cell: None, export: ExportOptions::default(), - show_setup: true, show_warnings: false, } } } impl TableGeneratorState { - /// Open the window on a generator. - pub fn open_for(&mut self, kind: GeneratorKind) { - self.open = true; - self.kind = kind; - self.selected_cell = None; - } - /// Status line for the tools panel, e.g. `2 logs, 14 events`. pub fn status(&self, kind: GeneratorKind) -> Option { let acc = self.accumulators.get(&kind)?; @@ -103,12 +96,32 @@ impl TableGeneratorState { ) } - fn generator(&self) -> &dyn TableAnalyzer { - self.generators[&self.kind].as_ref() + fn generator(&self, kind: GeneratorKind) -> &dyn TableAnalyzer { + self.generators[&kind].as_ref() + } + + /// Selected measure for `kind`, clamped to the accumulator's measure list. + pub fn measure_index(&self, kind: GeneratorKind) -> usize { + let n = self + .accumulators + .get(&kind) + .map(|a| a.measures.len()) + .unwrap_or(1); + self.measure + .get(&kind) + .copied() + .unwrap_or(0) + .min(n.saturating_sub(1)) + } + + /// The accumulator for `kind` when it holds at least one log. + pub fn table(&self, kind: GeneratorKind) -> Option<&TableAccumulator> { + self.accumulators.get(&kind).filter(|a| !a.logs.is_empty()) } - fn measure_index(&self) -> usize { - self.measure.get(&self.kind).copied().unwrap_or(0) + /// Forget the selected cell (called when the active tool changes). + pub fn clear_selection(&mut self) { + self.selected_cell = None; } } @@ -135,108 +148,127 @@ fn fmt_value(v: f64, decimals: usize) -> String { } impl UltraLogApp { - /// Render the table generator window. - pub fn render_table_generator(&mut self, ctx: &egui::Context) { - if !self.table_generator.open { + /// Central-panel view for a table tool: results, or the empty state. + pub fn render_table_tool_view(&mut self, ui: &mut egui::Ui) { + let Some(kind) = self.active_tool.generator_kind() else { return; - } - let mut open = true; - egui::Window::new(t!("table_gen.window_title")) - .open(&mut open) - .resizable(true) - .default_width(760.0) - .default_height(640.0) - .order(egui::Order::Foreground) - .show(ctx, |ui| { - self.render_table_generator_body(ui); + }; + let has_table = self.table_generator.table(kind).is_some(); + let file_index = self.selected_file.filter(|&i| i < self.files.len()); + + if !has_table { + ui.vertical_centered(|ui| { + ui.add_space(30.0); + if file_index.is_none() { + ui.label( + egui::RichText::new(t!("analysis.no_file_loaded")) + .color(egui::Color32::GRAY) + .size(16.0), + ); + ui.label( + egui::RichText::new(t!("analysis.load_file_help")) + .color(egui::Color32::GRAY) + .small(), + ); + } else { + ui.label( + egui::RichText::new(self.table_generator.generator(kind).description()) + .color(egui::Color32::GRAY) + .size(14.0), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(t!("table_gen.configure_hint")) + .color(egui::Color32::GRAY) + .small(), + ); + if let Some(report) = self.table_generator.last_report.get(&kind) { + ui.add_space(12.0); + ui.label( + egui::RichText::new(format!( + "{}: {}", + report.log_name, + report.summary() + )) + .color(egui::Color32::from_rgb(230, 170, 50)), + ); + for w in &report.warnings { + ui.label( + egui::RichText::new(format!("• {w}")) + .small() + .color(egui::Color32::GRAY), + ); + } + } + if let Some(err) = self.table_generator.last_error.get(&kind) { + ui.add_space(8.0); + ui.label( + egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80)), + ); + } + } + ui.add_space(30.0); }); - if !open { - self.table_generator.open = false; + return; } + + egui::ScrollArea::vertical() + .id_salt(format!("table_tool_view_{}", kind.id())) + .show(ui, |ui| { + self.render_table_results(ui, kind); + }); } - fn render_table_generator_body(&mut self, ui: &mut egui::Ui) { - // Generator switcher. - ui.horizontal(|ui| { - for kind in GeneratorKind::ALL { - let name = self.table_generator.generators[&kind].name(); - let selected = self.table_generator.kind == kind; - if ui.selectable_label(selected, name).clicked() && !selected { - self.table_generator.kind = kind; - self.table_generator.selected_cell = None; - self.table_generator.last_error = None; - } - } - }); + /// Tool Properties panel content for a table tool: the setup controls. + pub fn render_table_tool_properties(&mut self, ui: &mut egui::Ui) { + let Some(kind) = self.active_tool.generator_kind() else { + return; + }; + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + ui.label( + egui::RichText::new(self.table_generator.generator(kind).name()) + .size(font_14) + .strong(), + ); ui.label( - egui::RichText::new(self.table_generator.generator().description()) - .small() + egui::RichText::new(self.table_generator.generator(kind).description()) + .size(font_12) .color(egui::Color32::GRAY), ); - ui.add_space(4.0); + ui.add_space(8.0); let file_index = self.selected_file.filter(|&i| i < self.files.len()); - if file_index.is_none() - && self - .table_generator - .accumulators - .get(&self.table_generator.kind) - .is_none_or(|a| a.is_empty()) - { - ui.vertical_centered(|ui| { - ui.add_space(30.0); - ui.label( - egui::RichText::new(t!("analysis.no_file_loaded")) - .color(egui::Color32::GRAY) - .size(16.0), - ); - ui.label( - egui::RichText::new(t!("analysis.load_file_help")) - .color(egui::Color32::GRAY) - .small(), - ); - ui.add_space(30.0); - }); + let Some(fi) = file_index else { + ui.label( + egui::RichText::new(t!("table_gen.load_file_hint")) + .size(font_12) + .color(egui::Color32::GRAY), + ); return; - } - - if let Some(fi) = file_index { - self.ensure_table_mapping(fi); - } + }; + self.ensure_table_mapping(kind, fi); - egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(fi) = file_index { - let kind = self.table_generator.kind; - let header = t!( - "table_gen.setup_header", - file = self.files[fi].name.as_str() - ); - let open_default = self - .table_generator - .accumulators - .get(&kind) - .is_none_or(|a| a.is_empty()); - egui::CollapsingHeader::new(egui::RichText::new(header.as_ref()).strong()) - .default_open(open_default) - .open(if self.table_generator.show_setup { - None - } else { - Some(false) - }) - .show(ui, |ui| { - self.table_generator.show_setup = true; - self.render_table_setup(ui, fi); - }); - } - ui.add_space(6.0); - self.render_table_results(ui); - }); + ui.label( + egui::RichText::new(t!( + "table_gen.setup_header", + file = self.files[fi].name.as_str() + )) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + egui::ScrollArea::vertical() + .id_salt(format!("table_tool_props_{}", kind.id())) + .show(ui, |ui| { + self.render_table_setup(ui, kind, fi); + }); } /// Make sure a mapping exists for the current generator and file, /// re-suggesting when the file changed. - fn ensure_table_mapping(&mut self, file_index: usize) { - let kind = self.table_generator.kind; + fn ensure_table_mapping(&mut self, kind: GeneratorKind, file_index: usize) { let load_id = self.files[file_index].load_id; if self .table_generator @@ -246,11 +278,15 @@ impl UltraLogApp { { return; } - self.suggest_table_mapping(file_index, true); + self.suggest_table_mapping(kind, file_index, true); } - fn suggest_table_mapping(&mut self, file_index: usize, keep_overrides: bool) { - let kind = self.table_generator.kind; + fn suggest_table_mapping( + &mut self, + kind: GeneratorKind, + file_index: usize, + keep_overrides: bool, + ) { let file = &self.files[file_index]; let generator = &self.table_generator.generators[&kind]; let suggestion = suggest_mapping( @@ -282,8 +318,7 @@ impl UltraLogApp { self.table_generator.mappings.insert(kind, state); } - fn render_table_setup(&mut self, ui: &mut egui::Ui, file_index: usize) { - let kind = self.table_generator.kind; + fn render_table_setup(&mut self, ui: &mut egui::Ui, kind: GeneratorKind, file_index: usize) { let channel_names: Vec = self.files[file_index] .log .channels @@ -325,7 +360,7 @@ impl UltraLogApp { kind.id(), spec.role )) - .width(260.0) + .width((ui.available_width() - 40.0).clamp(120.0, 320.0)) .selected_text(shown) .show_ui(ui, |ui| { if ui @@ -400,34 +435,37 @@ impl UltraLogApp { .data_mut(|d| d.insert_temp(egui::Id::new("table_gen_reaxis"), true)); } }); - egui::Grid::new(format!("table_gen_axes_{}", kind.id())) - .num_columns(3) - .spacing([8.0, 4.0]) - .show(ui, |ui| { - for (label, text, axis) in [ - (state.axes.0.header(), &mut state.x_text, &mut state.axes.0), - (state.axes.1.header(), &mut state.y_text, &mut state.axes.1), - ] { - ui.label(label); - let resp = ui.add(egui::TextEdit::singleline(text).desired_width(420.0)); - if resp.changed() - && let Some(edges) = AxisSpec::parse_edges(text.as_str()) - { - *axis = AxisSpec::new(axis.label.clone(), axis.unit.clone(), edges); - } - let valid = AxisSpec::parse_edges(text.as_str()).is_some(); - ui.label(if valid { - egui::RichText::new(t!("table_gen.bins", n = axis.bins())) - .small() - .color(egui::Color32::GRAY) - } else { - egui::RichText::new(t!("table_gen.invalid_axis")) - .small() - .color(egui::Color32::from_rgb(220, 80, 80)) - }); - ui.end_row(); - } + for (i, (text, axis)) in [ + (&mut state.x_text, &mut state.axes.0), + (&mut state.y_text, &mut state.axes.1), + ] + .into_iter() + .enumerate() + { + let valid = AxisSpec::parse_edges(text.as_str()).is_some(); + ui.horizontal(|ui| { + ui.label(axis.header()); + ui.label(if valid { + egui::RichText::new(t!("table_gen.bins", n = axis.bins())) + .small() + .color(egui::Color32::GRAY) + } else { + egui::RichText::new(t!("table_gen.invalid_axis")) + .small() + .color(egui::Color32::from_rgb(220, 80, 80)) + }); }); + let resp = ui.add( + egui::TextEdit::singleline(text) + .id_salt(format!("table_gen_axis_{}_{}", kind.id(), i)) + .desired_width(f32::INFINITY), + ); + if resp.changed() + && let Some(edges) = AxisSpec::parse_edges(text.as_str()) + { + *axis = AxisSpec::new(axis.label.clone(), axis.unit.clone(), edges); + } + } // --- Parameters --------------------------------------------------- ui.add_space(6.0); @@ -547,7 +585,7 @@ impl UltraLogApp { .add_enabled(missing.is_empty() && axes_ok, button) .clicked() { - self.run_table_generator(file_index); + self.run_table_generator(kind, file_index); } if !missing.is_empty() { let names: Vec<&str> = missing.iter().map(|r| r.label()).collect(); @@ -558,7 +596,7 @@ impl UltraLogApp { ); } }); - if let Some(err) = &self.table_generator.last_error { + if let Some(err) = self.table_generator.last_error.get(&kind) { ui.label(egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80))); } @@ -572,7 +610,7 @@ impl UltraLogApp { .data_mut(|d| d.remove_temp::(egui::Id::new("table_gen_reaxis"))) .unwrap_or(false); if redetect { - self.suggest_table_mapping(file_index, false); + self.suggest_table_mapping(kind, file_index, false); } else if reaxis && let Some(state) = self.table_generator.mappings.get_mut(&kind) { let generator = &self.table_generator.generators[&kind]; state.axes = generator.default_axes(&self.files[file_index].log, &state.mapping); @@ -584,8 +622,7 @@ impl UltraLogApp { /// Run the current generator on `file_index` and fold the events into /// the accumulator. Axes are frozen when the accumulator is created; a /// later file with different axes replaces the table. - fn run_table_generator(&mut self, file_index: usize) { - let kind = self.table_generator.kind; + fn run_table_generator(&mut self, kind: GeneratorKind, file_index: usize) { let Some(state) = self.table_generator.mappings.get(&kind).cloned() else { return; }; @@ -632,20 +669,18 @@ impl UltraLogApp { let summary = report.summary(); acc.add_log(file.load_id, &file.name, events, report.clone()); self.table_generator.last_report.insert(kind, report); - self.table_generator.last_error = None; + self.table_generator.last_error.remove(&kind); self.table_generator.selected_cell = None; - self.table_generator.show_setup = false; self.show_toast(&summary); } Err(e) => { - self.table_generator.last_error = Some(e.to_string()); + self.table_generator.last_error.insert(kind, e.to_string()); self.show_toast_error(&e.to_string()); } } } - fn render_table_results(&mut self, ui: &mut egui::Ui) { - let kind = self.table_generator.kind; + fn render_table_results(&mut self, ui: &mut egui::Ui, kind: GeneratorKind) { let Some(acc) = self.table_generator.accumulators.get(&kind) else { return; }; @@ -653,10 +688,7 @@ impl UltraLogApp { return; } let measures = acc.measures.clone(); - let measure_idx = self - .table_generator - .measure_index() - .min(measures.len().saturating_sub(1)); + let measure_idx = self.table_generator.measure_index(kind); let grid = acc.grid(measure_idx); let logs: Vec<(u64, String)> = acc.logs.iter().map(|l| (l.id, l.name.clone())).collect(); let accepted = acc.accepted_count(); @@ -884,10 +916,9 @@ impl UltraLogApp { } self.table_generator.last_report.remove(&kind); self.table_generator.selected_cell = None; - self.table_generator.show_setup = true; } if export_csv { - self.export_table_csv(); + self.export_table_csv(kind); } if copy { let acc = &self.table_generator.accumulators[&kind]; @@ -1067,19 +1098,30 @@ impl UltraLogApp { self.show_toast_warning(&t!("table_gen.log_unloaded")); return; }; - if let Some(tab_idx) = self.tabs.iter().position(|t| t.file_index == file_index) { - self.active_tab = Some(tab_idx); - self.selected_file = Some(file_index); + { + // Reopens the tab if it was closed with Cmd+W; the file is still loaded. + self.switch_to_file_tab(file_index); + self.set_active_tool(crate::state::ActiveTool::LogViewer); + // Same sequence as the min/max jump buttons in channels.rs. The + // record is looked up on the event's own file, not `files.first()` + // as `find_record_at_time` does. + let times = self.files[file_index].log.get_times_as_f64(); + let record = times + .partition_point(|&t| t < time) + .min(times.len().saturating_sub(1)); + self.set_cursor_time(Some(time)); + self.set_cursor_record(Some(record)); self.set_jump_to_time(Some(time)); + self.is_playing = false; + self.last_frame_time = None; } } - fn export_table_csv(&mut self) { - let kind = self.table_generator.kind; + fn export_table_csv(&mut self, kind: GeneratorKind) { let Some(acc) = self.table_generator.accumulators.get(&kind) else { return; }; - let measure_idx = self.table_generator.measure_index(); + let measure_idx = self.table_generator.measure_index(kind); let generated = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); let csv = to_csv(acc, measure_idx, &self.table_generator.export, &generated); let Some(path) = rfd::FileDialog::new() @@ -1101,3 +1143,50 @@ impl UltraLogApp { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::tables::{AxisSpec, MeasureSpec}; + + #[test] + fn measure_index_clamps_to_the_accumulator() { + let mut state = TableGeneratorState::default(); + let kind = GeneratorKind::LambdaDelay; + // No accumulator yet: whatever is stored, the index is 0. + state.measure.insert(kind, 7); + assert_eq!(state.measure_index(kind), 0); + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0]); + let measures = vec![ + MeasureSpec { + key: "a", + label: "a", + unit: "ms", + decimals: 0, + }, + MeasureSpec { + key: "b", + label: "b", + unit: "ms", + decimals: 0, + }, + ]; + state + .accumulators + .insert(kind, TableAccumulator::new(kind, (x, y), measures)); + assert_eq!(state.measure_index(kind), 1); + state.measure.insert(kind, 0); + assert_eq!(state.measure_index(kind), 0); + } + + #[test] + fn clear_selection_forgets_the_cell() { + let mut state = TableGeneratorState { + selected_cell: Some((1, 2)), + ..Default::default() + }; + state.clear_selection(); + assert_eq!(state.selected_cell, None); + } +} diff --git a/src/ui/tool_properties_panel.rs b/src/ui/tool_properties_panel.rs index 893c088..b6fcf83 100644 --- a/src/ui/tool_properties_panel.rs +++ b/src/ui/tool_properties_panel.rs @@ -19,6 +19,9 @@ impl UltraLogApp { ActiveTool::LogViewer => self.render_log_viewer_properties(ui), ActiveTool::Histogram => self.render_histogram_properties(ui), ActiveTool::ScatterPlot => self.render_scatter_plot_properties(ui), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + self.render_table_tool_properties(ui) + } } } diff --git a/src/ui/tool_switcher.rs b/src/ui/tool_switcher.rs index 2ac0ca8..36054ea 100644 --- a/src/ui/tool_switcher.rs +++ b/src/ui/tool_switcher.rs @@ -6,7 +6,6 @@ use eframe::egui; use rust_i18n::t; -use crate::analytics; use crate::app::UltraLogApp; use crate::state::ActiveTool; @@ -16,14 +15,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.add_space(10.0); - // Define available tools - let tools = [ - ActiveTool::LogViewer, - ActiveTool::ScatterPlot, - ActiveTool::Histogram, - ]; - - for tool in tools { + for tool in ActiveTool::ALL { let is_selected = self.active_tool == tool; // Style the button based on selection state @@ -50,6 +42,8 @@ impl UltraLogApp { ActiveTool::LogViewer => t!("tools.log_viewer"), ActiveTool::ScatterPlot => t!("tools.scatter_plots"), ActiveTool::Histogram => t!("tools.histogram"), + ActiveTool::LambdaDelay => t!("tools.lambda_delay"), + ActiveTool::AccelEnrich => t!("tools.accel_enrich"), }; // Create pill-style button @@ -66,8 +60,7 @@ impl UltraLogApp { ); if response.clicked() { - self.active_tool = tool; - analytics::track_tool_switched(tool.name()); + self.set_active_tool(tool); } if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs index 8265385..43ec0af 100644 --- a/src/ui/tools_panel.rs +++ b/src/ui/tools_panel.rs @@ -144,9 +144,11 @@ impl UltraLogApp { let status = self.table_generator.status(kind); let enabled = has_file || status.is_some(); ui.horizontal(|ui| { - let btn = egui::Button::new(egui::RichText::new(name).size(font_12)); + let tool = ActiveTool::for_generator(kind); + let btn = egui::Button::new(egui::RichText::new(name).size(font_12)) + .selected(self.active_tool == tool); if ui.add_enabled(enabled, btn).clicked() { - self.table_generator.open_for(kind); + self.set_active_tool(tool); } if let Some(status) = status { ui.label( @@ -321,19 +323,20 @@ impl UltraLogApp { let has_file = self.selected_file.is_some() && !self.files.is_empty(); let has_channels = !self.get_selected_channels().is_empty(); + let has_table = self + .active_tool + .generator_kind() + .is_some_and(|k| self.table_generator.table(k).is_some()); // 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, + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => has_table, }; - let can_export_pdf = match self.active_tool { - ActiveTool::LogViewer => has_file && has_channels, - ActiveTool::ScatterPlot => has_file, - ActiveTool::Histogram => has_file, - }; + let can_export_pdf = can_export_png; ui.horizontal(|ui| { // PNG Export @@ -363,6 +366,9 @@ impl UltraLogApp { ActiveTool::LogViewer => self.export_chart_png(), ActiveTool::ScatterPlot => self.export_scatter_plot_png(), ActiveTool::Histogram => self.export_histogram_png(), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + self.export_table_png() + } } } @@ -398,6 +404,9 @@ impl UltraLogApp { ActiveTool::LogViewer => self.export_chart_pdf(), ActiveTool::ScatterPlot => self.export_scatter_plot_pdf(), ActiveTool::Histogram => self.export_histogram_pdf(), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + self.export_table_pdf() + } } } diff --git a/tests/core/state_tests.rs b/tests/core/state_tests.rs index b08fce2..3f84777 100644 --- a/tests/core/state_tests.rs +++ b/tests/core/state_tests.rs @@ -187,6 +187,35 @@ 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"); + assert_eq!(ActiveTool::LambdaDelay.name(), "Lambda Delay"); + assert_eq!(ActiveTool::AccelEnrich.name(), "Accel Enrichment"); +} + +#[test] +fn test_active_tool_all_is_shortcut_order() { + // Cmd+1..5 index into this array; the first three must not move. + assert_eq!(ActiveTool::ALL.len(), 5); + assert!(ActiveTool::ALL[0] == ActiveTool::LogViewer); + assert!(ActiveTool::ALL[1] == ActiveTool::ScatterPlot); + assert!(ActiveTool::ALL[2] == ActiveTool::Histogram); + assert!(ActiveTool::ALL[3] == ActiveTool::LambdaDelay); + assert!(ActiveTool::ALL[4] == ActiveTool::AccelEnrich); +} + +#[test] +fn test_active_tool_generator_kind_round_trips() { + use ultralog::analysis::tables::GeneratorKind; + for tool in ActiveTool::ALL { + match tool.generator_kind() { + Some(kind) => assert!(ActiveTool::for_generator(kind) == tool), + None => assert!(matches!( + tool, + ActiveTool::LogViewer | ActiveTool::ScatterPlot | ActiveTool::Histogram + )), + } + } + assert!(ActiveTool::LambdaDelay.generator_kind() == Some(GeneratorKind::LambdaDelay)); + assert!(ActiveTool::AccelEnrich.generator_kind() == Some(GeneratorKind::AccelEnrich)); } #[test]