From 26d29bb3d012941c4ad56a4caa8b3da8b4c1288d Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 13:54:36 +0100 Subject: [PATCH 01/13] feat: add math channel --- index.html | 73 ++++++++++ src/mathchannels.js | 332 ++++++++++++++++++++++++++++++++++++++++++ src/palettemanager.js | 158 +++++++++++--------- src/ui.js | 66 +++++++-- 4 files changed, 548 insertions(+), 81 deletions(-) create mode 100644 src/mathchannels.js diff --git a/index.html b/index.html index 1153c9b..31db1e7 100644 --- a/index.html +++ b/index.html @@ -288,6 +288,27 @@

View Controls

+
+

Math Channels

+ +
+

+ Create virtual sensors using formulas (e.g. Boost, HP). +

+ + +
+
+

Anomaly Scanner

@@ -853,6 +874,57 @@

XY Scatter Analysis

+ + diff --git a/src/style.css b/src/style.css index b9c1f97..19cde63 100644 --- a/src/style.css +++ b/src/style.css @@ -24,6 +24,10 @@ --nav-shadow: 0 4px 30px rgb(0 0 0 / 10%); --input-bg: #fff; --input-border: #ddd; + --btn-radius: 8px; + --btn-shadow: 0 2px 5px rgb(0 0 0 / 10%); + --btn-hover-shadow: 0 4px 10px rgb(0 0 0 / 15%); + --btn-font-weight: 600; } body.dark-theme { @@ -255,15 +259,38 @@ input[type='number'] { } .btn { - background: white; - border: 1px solid #ccc; - padding: 6px 12px; + border-radius: var(--btn-radius); + padding: 10px 16px; + font-size: 0.9rem; + font-weight: var(--btn-font-weight); cursor: pointer; - border-radius: 4px; - font-weight: 600; - color: #333; - width: 100%; - transition: all 0.2s; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + background-color: #fff; + color: var(--text-main); + border: 1px solid var(--border-color); + box-shadow: var(--btn-shadow); +} + +.btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--btn-hover-shadow); + background-color: #f8f9fa; +} + +.btn:active:not(:disabled) { + transform: translateY(0); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; + filter: grayscale(0.4); + pointer-events: none; } .btn:hover { @@ -271,19 +298,41 @@ input[type='number'] { } .btn-primary { - background: var(--brand-green); + background-color: var(--brand-green); color: white; border: none; } +.btn-primary:hover:not(:disabled) { + background-color: #016b42; +} + .btn-primary:hover { background: #01633d; } +.btn-add, +.btn-secondary { + background-color: var(--brand-blue); + color: white; + border: none; +} + +.btn-add:hover:not(:disabled), +.btn-secondary:hover:not(:disabled) { + background-color: #152e56; +} + +.btn-danger { + background-color: var(--brand-red); + color: white; + border: none; +} + .btn-sm { - width: auto; - padding: 2px 8px; - font-size: 0.8em; + padding: 4px 10px; + font-size: 0.8rem; + border-radius: 6px; } .btn-add { @@ -2127,15 +2176,13 @@ body.analyzer-active { margin-bottom: 15px; } -.source-btn { - flex: 1; - border: none; - border-radius: 10px; - padding: 12px 5px; - cursor: pointer; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; + +.template-select, +input[type='text'], +input[type='number'] { + border-radius: var(--btn-radius) !important; + padding: 10px !important; + border: 1px solid var(--border-color); } .btn-content { @@ -2239,18 +2286,23 @@ body.analyzer-active { .view-mode-btn { flex: 1; padding: 10px; - font-size: 0.9rem; - font-weight: 500; + border-radius: var(--btn-radius); border: 1px solid var(--border-color); - background-color: white; + background: #fff; color: var(--text-muted); - border-radius: 6px; + font-weight: 500; cursor: pointer; + transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 6px; - transition: all 0.2s ease; + box-shadow: var(--btn-shadow); +} + +.view-mode-btn:hover:not(.active) { + background-color: #f1f3f5; + color: var(--text-main); } .view-mode-btn:not(.active):hover { @@ -2264,7 +2316,7 @@ body.analyzer-active { color: white; border-color: var(--brand-blue); font-weight: 600; - box-shadow: 0 4px 6px rgb(28 61 114 / 20%); + box-shadow: 0 4px 8px rgb(28 61 114 / 30%); } .view-mode-btn.active i { From d182d85bcea1eae949a2cd2cec530dbb0466356a Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 17:20:28 +0100 Subject: [PATCH 09/13] feat: fix css formatting --- src/style.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/style.css b/src/style.css index 19cde63..b885e84 100644 --- a/src/style.css +++ b/src/style.css @@ -329,6 +329,12 @@ input[type='number'] { border: none; } +.btn-danger:hover:not(:disabled) { + background-color: #a01e2b; + color: white; + box-shadow: var(--btn-hover-shadow); +} + .btn-sm { padding: 4px 10px; font-size: 0.8rem; @@ -2176,7 +2182,6 @@ body.analyzer-active { margin-bottom: 15px; } - .template-select, input[type='text'], input[type='number'] { From 10a7d739bcc75ef8263ad8739da5f09ef16085cc Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 17:32:12 +0100 Subject: [PATCH 10/13] feat: add rule to calculate power from torque --- src/mathchannels.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/mathchannels.js b/src/mathchannels.js index f3911bf..2ac55bd 100644 --- a/src/mathchannels.js +++ b/src/mathchannels.js @@ -42,6 +42,17 @@ class MathChannels { ], formula: (values) => values[0] * values[1], }, + { + id: 'power_from_torque', + name: 'Calculated Power (HP) [Source: Torque]', + description: + 'Calculates HP from Torque (Nm) and RPM. (Torque * RPM / 7127)', + inputs: [ + { name: 'torque', label: 'Torque (Nm)' }, + { name: 'rpm', label: 'Engine RPM' }, + ], + formula: (values) => (values[0] * values[1]) / 7127, + }, { id: 'boost', name: 'Boost Pressure (Bar)', @@ -213,11 +224,12 @@ class MathChannels { } #openModal() { + if (AppState.files.length === 0) { alert('Please load a log file first.'); return; } - + const modal = document.getElementById('mathModal'); if (modal) modal.style.display = 'flex'; From d20dfb1db84470e23b081bd4ca38f388d88c22a8 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 17:36:43 +0100 Subject: [PATCH 11/13] feat: add tests for power calculation --- src/mathchannels.js | 3 +-- tests/mathchannels.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/mathchannels.js b/src/mathchannels.js index 2ac55bd..8f640ae 100644 --- a/src/mathchannels.js +++ b/src/mathchannels.js @@ -224,12 +224,11 @@ class MathChannels { } #openModal() { - if (AppState.files.length === 0) { alert('Please load a log file first.'); return; } - + const modal = document.getElementById('mathModal'); if (modal) modal.style.display = 'flex'; diff --git a/tests/mathchannels.test.js b/tests/mathchannels.test.js index 4639a3d..553c5cc 100644 --- a/tests/mathchannels.test.js +++ b/tests/mathchannels.test.js @@ -68,6 +68,44 @@ describe('MathChannels', () => { }).toThrow("Signal 'MissingSignal' not found"); }); + test('Calculated Power from Torque (Torque * RPM / 7127)', () => { + // Torque = 400 Nm, RPM = 5000 + // HP = (400 * 5000) / 7127 = 280.62... + const torqueData = [{ x: 100, y: 400 }]; + const rpmData = [{ x: 100, y: 5000 }]; + + AppState.files = [ + { + signals: { TQ: torqueData, RPM: rpmData }, + availableSignals: ['TQ', 'RPM'], + }, + ]; + + mathChannels.createChannel(0, 'power_from_torque', ['TQ', 'RPM'], 'HP'); + const result = AppState.files[0].signals['HP']; + + const expected = (400 * 5000) / 7127; + expect(result[0].y).toBeCloseTo(expected, 4); + }); + + test('Estimated Power from kg/h (MAF/3.6 * Factor)', () => { + // MAF = 360 kg/h -> 100 g/s. Factor = 1.35. Result should be 135. + const mafData = [{ x: 1, y: 360 }]; + + AppState.files = [ + { + signals: { MAF: mafData }, + availableSignals: ['MAF'], + }, + ]; + + // Input 0: Signal, Input 1: Constant + mathChannels.createChannel(0, 'est_power_kgh', ['MAF', '1.35'], 'EstHP'); + const result = AppState.files[0].signals['EstHP']; + + expect(result[0].y).toBeCloseTo(135, 1); + }); + test('successfully creates a channel with Multiply by Constant (Signal + Constant)', () => { const signalData = [ { x: 0, y: 10 }, From 6f38eb7307ad02ec01b30551030d0f70aef96333 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 19:18:55 +0100 Subject: [PATCH 12/13] feat: seperate signals from math channels and regular signals --- src/style.css | 27 +++++++++++++++++++++++++++ src/ui.js | 47 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/style.css b/src/style.css index b885e84..07d391d 100644 --- a/src/style.css +++ b/src/style.css @@ -2445,3 +2445,30 @@ input[type='number'] { .empty-state-container { background: transparent !important; } + +.signal-separator { + display: flex; + align-items: center; + text-align: center; + margin: 10px 0 5px; + color: var(--text-muted); + font-size: 0.7em; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.signal-separator::before, +.signal-separator::after { + content: ''; + flex: 1; + border-bottom: 1px dashed var(--border-color); +} + +.signal-separator::before { + margin-right: 0.5em; +} + +.signal-separator::after { + margin-left: 0.5em; +} diff --git a/src/ui.js b/src/ui.js index e3e31b2..9898ceb 100644 --- a/src/ui.js +++ b/src/ui.js @@ -373,12 +373,19 @@ export const UI = { : 'fas fa-chevron-right'; }; - file.availableSignals.forEach((signal, sigIdx) => { + const mathSignals = file.availableSignals + .filter((s) => s.startsWith('Math:')) + .sort(); + const regularSignals = file.availableSignals + .filter((s) => !s.startsWith('Math:')) + .sort(); + + const createSignalItem = (signal) => { + const sigIdx = file.availableSignals.indexOf(signal); const isMath = signal.startsWith('Math:'); const isImportant = DEFAULT_SIGNALS.some((k) => signal.includes(k)); let isCurrentlyVisible = false; - if (ChartManager.viewMode === 'overlay') { const chart = AppState.chartInstances[0]; if (chart) { @@ -396,9 +403,9 @@ export const UI = { } const isChecked = isCurrentlyVisible || isImportant; - const color = PaletteManager.getColorForSignal(fileIdx, sigIdx); const signalKey = PaletteManager.getSignalKey(file.name, signal); + const uniqueId = `chk-f${fileIdx}-s${sigIdx}`; const signalItem = document.createElement('div'); signalItem.className = 'signal-item'; @@ -406,7 +413,6 @@ export const UI = { signalItem.style.cssText = 'display: flex; align-items: center; gap: 8px; padding: 2px 5px;'; - const uniqueId = `chk-f${fileIdx}-s${sigIdx}`; const pickerStyle = `width: 18px; height: 18px; border: none; padding: 0; background: none; cursor: ${isCustomEnabled ? 'pointer' : 'default'}; opacity: ${isCustomEnabled ? '1' : '0.4'};`; const labelStyle = isMath @@ -419,7 +425,7 @@ export const UI = { data-signal-key="${signalKey}"> - `; + `; const picker = signalItem.querySelector('.signal-color-picker'); picker.onchange = (e) => { @@ -429,7 +435,31 @@ export const UI = { if (typeof ChartManager !== 'undefined') ChartManager.render(); }; - signalListContainer.appendChild(signalItem); + return signalItem; + }; + + if (mathSignals.length > 0) { + const mathSeparator = document.createElement('div'); + mathSeparator.className = 'signal-separator'; + mathSeparator.innerText = 'Math Channels'; + mathSeparator.setAttribute('data-type', 'separator'); + signalListContainer.appendChild(mathSeparator); + + mathSignals.forEach((signal) => { + signalListContainer.appendChild(createSignalItem(signal)); + }); + } + + if (mathSignals.length > 0 && regularSignals.length > 0) { + const separator = document.createElement('div'); + separator.className = 'signal-separator'; + separator.innerText = 'Log Data'; + separator.setAttribute('data-type', 'separator'); + signalListContainer.appendChild(separator); + } + + regularSignals.forEach((signal) => { + signalListContainer.appendChild(createSignalItem(signal)); }); fileGroup.appendChild(fileHeader); @@ -454,6 +484,7 @@ export const UI = { groups.forEach((group) => { let matchCount = 0; const items = group.querySelectorAll('.signal-item'); + const separators = group.querySelectorAll('.signal-separator'); items.forEach((item) => { const attr = item.getAttribute('data-signal-name'); @@ -462,6 +493,10 @@ export const UI = { if (isMatch) matchCount++; }); + separators.forEach((sep) => { + sep.style.display = term.length > 0 ? 'none' : 'flex'; + }); + group.style.display = matchCount > 0 ? 'block' : 'none'; const sigList = group.querySelector('[id^="sig-list-f"]'); if (term.length > 0 && matchCount > 0 && sigList) From 4ed996e3221a360f8189533f31c4685a56ae8557 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 19:46:46 +0100 Subject: [PATCH 13/13] feat: add custom process for match channels --- src/mathchannels.js | 142 +++++++++++++++++++++++++++++++++++++ tests/mathchannels.test.js | 105 +++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/src/mathchannels.js b/src/mathchannels.js index 8f640ae..4b01219 100644 --- a/src/mathchannels.js +++ b/src/mathchannels.js @@ -11,6 +11,79 @@ class MathChannels { #getDefinitions() { return [ + { + id: 'acceleration', + name: 'Acceleration (m/s²) [0-100 Logic]', + description: + 'Calculates acceleration from Speed. Use window to smooth noise.', + inputs: [ + { name: 'speed', label: 'Speed (km/h)' }, + { + name: 'window', + label: 'Smoothing Window (Samples)', + isConstant: true, + defaultValue: 4, + }, + ], + customProcess: (sourceData, constants) => { + const windowSize = Math.max(1, Math.round(constants[0])); + const result = []; + + for (let i = windowSize; i < sourceData.length; i++) { + const p1 = sourceData[i - windowSize]; + const p2 = sourceData[i]; + + const dt = (p2.x - p1.x) / 1000; + if (dt <= 0) continue; + + const dv = (p2.y - p1.y) / 3.6; + + const accel = dv / dt; + + result.push({ + x: p2.x, + y: accel, + }); + } + return result; + }, + }, + { + id: 'smoothing', + name: 'Smooth Signal (Moving Average)', + description: 'Reduces noise by averaging the last N samples.', + inputs: [ + { name: 'source', label: 'Signal to Smooth' }, + { + name: 'window', + label: 'Window Size (Samples)', + isConstant: true, + defaultValue: 5, + }, + ], + customProcess: (sourceData, constants) => { + const windowSize = Math.max(1, Math.round(constants[0])); + const smoothed = []; + + for (let i = 0; i < sourceData.length; i++) { + let sum = 0; + let count = 0; + + for (let j = 0; j < windowSize; j++) { + if (i - j >= 0) { + sum += sourceData[i - j].y; + count++; + } + } + + smoothed.push({ + x: sourceData[i].x, + y: sum / count, + }); + } + return smoothed; + }, + }, { id: 'est_power_kgh', name: 'Estimated Power (HP) [Source: kg/h]', @@ -115,6 +188,15 @@ class MathChannels { const definition = this.#definitions.find((d) => d.id === formulaId); if (!definition) throw new Error('Invalid formula definition.'); + if (definition.customProcess) { + return this.#executeCustomProcess( + file, + definition, + inputMapping, + newChannelName + ); + } + const sourceSignals = []; let masterTimeBase = null; @@ -187,6 +269,66 @@ class MathChannels { return finalName; } + #executeCustomProcess(file, definition, inputMapping, newChannelName) { + // Extract inputs strictly for custom processor + const signals = []; + const constants = []; + + definition.inputs.forEach((input, idx) => { + if (input.isConstant) { + const val = parseFloat(inputMapping[idx]); + if (isNaN(val)) + throw new Error(`Invalid constant value for ${input.label}`); + constants.push(val); + } else { + const signalName = inputMapping[idx]; + const signalData = file.signals[signalName]; + if (!signalData) throw new Error(`Signal '${signalName}' not found.`); + signals.push(signalData); + } + }); + + // For smoothing, we assume 1 signal input. + // Generalizing this would require more complex mapping, but for now: + if (signals.length === 0) + throw new Error('Custom process requires at least one signal.'); + + // Run the custom processor logic defined in #getDefinitions + const resultData = definition.customProcess(signals[0], constants); + + return this.#finalizeChannel( + file, + resultData, + newChannelName || `Math: ${definition.name}` + ); + } + + #finalizeChannel(file, resultData, finalName) { + let min = Infinity; + let max = -Infinity; + + for (const point of resultData) { + if (point.y < min) min = point.y; + if (point.y > max) max = point.y; + } + + file.signals[finalName] = resultData; + + if (!file.metadata) file.metadata = {}; + file.metadata[finalName] = { + min: min, + max: max, + unit: 'Math', + }; + + if (!file.availableSignals.includes(finalName)) { + file.availableSignals.push(finalName); + file.availableSignals.sort(); + } + + return finalName; + } + #interpolate(data, targetTime) { if (!data || data.length === 0) return 0; diff --git a/tests/mathchannels.test.js b/tests/mathchannels.test.js index 553c5cc..e0af074 100644 --- a/tests/mathchannels.test.js +++ b/tests/mathchannels.test.js @@ -106,6 +106,23 @@ describe('MathChannels', () => { expect(result[0].y).toBeCloseTo(135, 1); }); + test('Estimated Power from g/s (MAF * Factor)', () => { + // MAF = 100 g/s. Factor = 1.35. Result should be 135. + const mafData = [{ x: 1, y: 100 }]; + + AppState.files = [ + { + signals: { MAF: mafData }, + availableSignals: ['MAF'], + }, + ]; + + mathChannels.createChannel(0, 'est_power_gs', ['MAF', '1.35'], 'EstHP'); + const result = AppState.files[0].signals['EstHP']; + + expect(result[0].y).toBeCloseTo(135, 1); + }); + test('successfully creates a channel with Multiply by Constant (Signal + Constant)', () => { const signalData = [ { x: 0, y: 10 }, @@ -258,6 +275,94 @@ describe('MathChannels', () => { expect(res[0].y).toBe(10 - 20); // -10 }); + + test('Acceleration (m/s²) - Custom Process', () => { + // 0 to 100 km/h in 5 seconds linear acceleration. + // 100 km/h = 27.777 m/s + // Accel = 27.777 / 5 = 5.55 m/s^2 + + const speedData = [ + { x: 0, y: 0 }, + { x: 1000, y: 20 }, // 20 km/h + { x: 2000, y: 40 }, // 40 km/h + ]; + // At t=1000 (1s), deltaV = 20 km/h = 5.555... m/s. deltaT = 1s. + + AppState.files = [ + { + signals: { Speed: speedData }, + availableSignals: ['Speed'], + }, + ]; + + // Input 0: Speed, Input 1: Window Size (1) + mathChannels.createChannel(0, 'acceleration', ['Speed', '1'], 'Accel'); + const result = AppState.files[0].signals['Accel']; + + // The logic loop starts at i=windowSize (1) + expect(result.length).toBeGreaterThan(0); + + // Obliczamy oczekiwaną wartość dokładnie tak jak kod: (20 km/h w m/s) / 1s + const expectedValue = 20 / 3.6 / 1; + + expect(result[0].y).toBeCloseTo(expectedValue, 4); + }); + + test('Smoothing (Moving Average) - Custom Process', () => { + const noisySignal = [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 10 }, + { x: 4, y: 20 }, + ]; + + AppState.files = [ + { + signals: { Noisy: noisySignal }, + availableSignals: ['Noisy'], + }, + ]; + + // Input 0: Signal, Input 1: Window Size (2) + mathChannels.createChannel(0, 'smoothing', ['Noisy', '2'], 'Smooth'); + const result = AppState.files[0].signals['Smooth']; + + // Index 0 (x=1): Avg(10) = 10 + expect(result[0].y).toBe(10); + + // Index 1 (x=2): Avg(10, 20) = 15 + expect(result[1].y).toBe(15); + + // Index 2 (x=3): Avg(20, 10) = 15 + expect(result[2].y).toBe(15); + }); + + test('Pressure Ratio (MAP / Baro) with Zero Division check', () => { + const mapData = [ + { x: 1, y: 2000 }, + { x: 2, y: 2000 }, + ]; + const baroData = [ + { x: 1, y: 1000 }, // Normal case + { x: 2, y: 0 }, // Edge case: Division by zero + ]; + + AppState.files = [ + { + signals: { MAP: mapData, Baro: baroData }, + availableSignals: ['MAP', 'Baro'], + }, + ]; + + mathChannels.createChannel(0, 'pressure_ratio', ['MAP', 'Baro'], 'PR'); + const result = AppState.files[0].signals['PR']; + + // Case 1: 2000 / 1000 = 2 + expect(result[0].y).toBe(2); + + // Case 2: 2000 / 0 -> Should be handled (returns 0 in formula) + expect(result[1].y).toBe(0); + }); }); describe('UI Interactions', () => {