diff --git a/src/mathchannels.js b/src/mathchannels.js index 4b01219..525154a 100644 --- a/src/mathchannels.js +++ b/src/mathchannels.js @@ -11,6 +11,48 @@ class MathChannels { #getDefinitions() { return [ + { + id: 'est_power_kgh', + name: 'Estimated Power (HP) [Source: kg/h]', + description: + 'Converts kg/h to g/s, then estimates HP. (MAF / 3.6 * Factor)', + inputs: [ + { name: 'maf', label: 'Air Mass Flow (kg/h)' }, + { + name: 'factor', + label: 'Factor (Diesel ~1.35, Petrol ~1.25)', + isConstant: true, + defaultValue: 1.35, + }, + ], + formula: (values) => (values[0] / 3.6) * values[1], + }, + { + id: 'est_power_gs', + name: 'Estimated Power (HP) [Source: g/s]', + description: 'Direct g/s calculation. (MAF * Factor)', + inputs: [ + { name: 'maf', label: 'Air Mass Flow (g/s)' }, + { + name: 'factor', + label: 'Factor (Diesel ~1.35, Petrol ~1.25)', + isConstant: true, + defaultValue: 1.35, + }, + ], + 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: 'acceleration', name: 'Acceleration (m/s²) [0-100 Logic]', @@ -37,13 +79,9 @@ class MathChannels { if (dt <= 0) continue; const dv = (p2.y - p1.y) / 3.6; - const accel = dv / dt; - result.push({ - x: p2.x, - y: accel, - }); + result.push({ x: p2.x, y: accel }); } return result; }, @@ -76,56 +114,11 @@ class MathChannels { } } - smoothed.push({ - x: sourceData[i].x, - y: sum / count, - }); + smoothed.push({ x: sourceData[i].x, y: sum / count }); } return smoothed; }, }, - { - id: 'est_power_kgh', - name: 'Estimated Power (HP) [Source: kg/h]', - description: - 'Converts kg/h to g/s, then estimates HP. (MAF / 3.6 * Factor)', - inputs: [ - { name: 'maf', label: 'Air Mass Flow (kg/h)' }, - { - name: 'factor', - label: 'Factor (Diesel ~1.35, Petrol ~1.25)', - isConstant: true, - defaultValue: 1.35, - }, - ], - formula: (values) => (values[0] / 3.6) * values[1], - }, - { - id: 'est_power_gs', - name: 'Estimated Power (HP) [Source: g/s]', - description: 'Direct g/s calculation. (MAF * Factor)', - inputs: [ - { name: 'maf', label: 'Air Mass Flow (g/s)' }, - { - name: 'factor', - label: 'Factor (Diesel ~1.35, Petrol ~1.25)', - isConstant: true, - defaultValue: 1.35, - }, - ], - 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)', @@ -214,7 +207,6 @@ class MathChannels { throw new Error(`Signal '${signalName}' not found in file.`); sourceSignals.push({ isConstant: false, data: signalData }); - if (!masterTimeBase) masterTimeBase = signalData; } }); @@ -223,9 +215,6 @@ class MathChannels { throw new Error('At least one input must be a signal.'); const resultData = []; - let min = Infinity; - let max = -Infinity; - for (let i = 0; i < masterTimeBase.length; i++) { const currentTime = masterTimeBase[i].x; const currentValues = []; @@ -240,37 +229,17 @@ class MathChannels { }); const calculatedY = definition.formula(currentValues); - - if (calculatedY < min) min = calculatedY; - if (calculatedY > max) max = calculatedY; - - resultData.push({ - x: currentTime, - y: calculatedY, - }); - } - - const finalName = newChannelName || `Math: ${definition.name}`; - - 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(); + resultData.push({ x: currentTime, y: calculatedY }); } - return finalName; + return this.#finalizeChannel( + file, + resultData, + newChannelName || `Math: ${definition.name}` + ); } #executeCustomProcess(file, definition, inputMapping, newChannelName) { - // Extract inputs strictly for custom processor const signals = []; const constants = []; @@ -288,14 +257,9 @@ class MathChannels { } }); - // 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, @@ -325,32 +289,25 @@ class MathChannels { file.availableSignals.push(finalName); file.availableSignals.sort(); } - return finalName; } #interpolate(data, targetTime) { if (!data || data.length === 0) return 0; - if (targetTime <= data[0].x) return parseFloat(data[0].y); if (targetTime >= data[data.length - 1].x) return parseFloat(data[data.length - 1].y); let left = 0; let right = data.length - 1; - while (left <= right) { const mid = Math.floor((left + right) / 2); - if (data[mid].x < targetTime) { - left = mid + 1; - } else { - right = mid - 1; - } + if (data[mid].x < targetTime) left = mid + 1; + else right = mid - 1; } const p1 = data[left - 1]; const p2 = data[left]; - if (!p1) return parseFloat(p2.y); if (!p2) return parseFloat(p1.y); @@ -358,11 +315,9 @@ class MathChannels { const t2 = p2.x; const y1 = parseFloat(p1.y); const y2 = parseFloat(p2.y); - if (t2 === t1) return y1; - const fraction = (targetTime - t1) / (t2 - t1); - return y1 + (y2 - y1) * fraction; + return y1 + (y2 - y1) * ((targetTime - t1) / (t2 - t1)); } #openModal() { @@ -370,7 +325,6 @@ class MathChannels { alert('Please load a log file first.'); return; } - const modal = document.getElementById('mathModal'); if (modal) modal.style.display = 'flex'; @@ -407,37 +361,97 @@ class MathChannels { return; } const file = AppState.files[0]; - const signalOptions = file.availableSignals - .map((s) => ``) - .join(''); definition.inputs.forEach((input, idx) => { const wrapper = document.createElement('div'); - wrapper.style.marginBottom = '10px'; + wrapper.style.marginBottom = '15px'; + + wrapper.innerHTML = ``; - let inputHtml = ''; if (input.isConstant) { - inputHtml = ``; + const inputEl = document.createElement('input'); + inputEl.type = 'number'; + inputEl.id = `math-input-${idx}`; + inputEl.value = input.defaultValue; + inputEl.className = 'template-select'; + inputEl.style.width = '100%'; + wrapper.appendChild(inputEl); } else { - inputHtml = ``; + const searchableSelect = this.#createSearchableSelect( + idx, + file.availableSignals, + input.name + ); + wrapper.appendChild(searchableSelect); } - - wrapper.innerHTML = ` - - ${inputHtml} - `; container.appendChild(wrapper); + }); + } - if (!input.isConstant) { - const sel = wrapper.querySelector('select'); - for (let opt of sel.options) { - if (opt.value.toLowerCase().includes(input.name.toLowerCase())) { - sel.value = opt.value; - break; - } - } + #createSearchableSelect(idx, signals, inputFilterName) { + const wrapper = document.createElement('div'); + wrapper.className = 'searchable-select-wrapper'; + + const input = document.createElement('input'); + input.type = 'text'; + input.id = `math-input-${idx}`; + input.className = 'searchable-input'; + input.placeholder = 'Search or Select Signal...'; + input.autocomplete = 'off'; + + const resultsList = document.createElement('div'); + resultsList.className = 'search-results-list'; + + const defaultSignal = signals.find((s) => + s.toLowerCase().includes(inputFilterName.toLowerCase()) + ); + if (defaultSignal) input.value = defaultSignal; + + const renderOptions = (filterText = '') => { + resultsList.innerHTML = ''; + const lowerFilter = filterText.toLowerCase(); + + const filtered = signals.filter((s) => + s.toLowerCase().includes(lowerFilter) + ); + + if (filtered.length === 0) { + resultsList.innerHTML = + '
No signals found
'; + } else { + filtered.forEach((sig) => { + const opt = document.createElement('div'); + opt.className = 'search-option'; + opt.textContent = sig; + opt.onclick = () => { + input.value = sig; + resultsList.style.display = 'none'; + }; + resultsList.appendChild(opt); + }); + } + }; + + input.addEventListener('focus', () => { + renderOptions(input.value); + resultsList.style.display = 'block'; + }); + + input.addEventListener('input', (e) => { + renderOptions(e.target.value); + resultsList.style.display = 'block'; + }); + + document.addEventListener('click', (e) => { + if (!wrapper.contains(e.target)) { + resultsList.style.display = 'none'; } }); + + wrapper.appendChild(input); + wrapper.appendChild(resultsList); + + return wrapper; } #executeCreation() { @@ -464,12 +478,9 @@ class MathChannels { inputMapping, newName ); - this.#closeModal(); - if (typeof UI.renderSignalList === 'function') { UI.renderSignalList(); - setTimeout(() => { const checkbox = document.querySelector( `input[data-key="${createdName}"]` diff --git a/src/style.css b/src/style.css index 07d391d..e999ece 100644 --- a/src/style.css +++ b/src/style.css @@ -34,12 +34,12 @@ body.dark-theme { --chart-canvas-bg: #1a1a1a; --text-main: #333; --text-muted: #666; - --card-bg: #1e1e1e; - --sidebar-bg: #252525; - --border-color: #333; - --input-bg: #2d2d2d; - --input-border: #444; - --text-color: #eee; + /* --card-bg: #1e1e1e; */ + /* --sidebar-bg: #252525; */ + /* --border-color: #333; */ + /* --input-bg: #2d2d2d; */ + /* --input-border: #444; */ + /* --text-color: #eee; */ } body.dark-theme .sidebar-left, @@ -830,6 +830,18 @@ input[type='range']::-webkit-slider-thumb { animation: modalPopIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } +#xyModal { + --card-bg: #fff; + --text-main: #333; + --text-muted: #666; + --text-color: #333; + --input-bg: #fff; + --input-border: #ddd; + --border-color: #ddd; + --chart-canvas-bg: #fff; + --active-bg: #f0f0f0; +} + #xyModal .modal-content { max-width: 95vw !important; height: 90vh !important; @@ -2472,3 +2484,64 @@ input[type='number'] { .signal-separator::after { margin-left: 0.5em; } + +.searchable-select-wrapper { + position: relative; + width: 100%; +} + +.searchable-input { + width: 100%; + box-sizing: border-box; /* Ważne, aby padding nie psuł szerokości */ + padding: 10px; + border-radius: var(--btn-radius); + border: 1px solid var(--border-color); + background-color: #fff; /* Jasne tło */ + color: var(--text-main); +} + +body.pref-theme-dark .searchable-input { + background-color: #2b2b2b; + color: #e0e0e0; + border-color: #444; +} + +.search-results-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: #fff; + border: 1px solid var(--border-color); + border-top: none; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + border-radius: 0 0 var(--btn-radius) var(--btn-radius); + box-shadow: 0 4px 6px rgb(0 0 0 / 15%); + display: none; +} + +body.pref-theme-dark .search-results-list { + background-color: #2b2b2b; + border-color: #444; +} + +.search-option { + padding: 8px 10px; + cursor: pointer; + font-size: 0.9em; + border-bottom: 1px solid var(--border-color); +} + +.search-option:last-child { + border-bottom: none; +} + +.search-option:hover { + background-color: #f0f0f0; +} + +body.pref-theme-dark .search-option:hover { + background-color: #3a3a3a; +} diff --git a/src/xyanalysis.js b/src/xyanalysis.js index 01df523..57bccd0 100644 --- a/src/xyanalysis.js +++ b/src/xyanalysis.js @@ -16,9 +16,34 @@ import { import zoomPlugin from 'chartjs-plugin-zoom'; import 'chartjs-adapter-date-fns'; -export const XYAnalysis = { - charts: [null, null], - timelineChart: null, +class XYAnalysisClass { + #charts = [null, null]; + #timelineChart = null; + #currentFileIndex = 0; + + get charts() { + return this.#charts; + } + + set charts(value) { + this.#charts = value; + } + + get timelineChart() { + return this.#timelineChart; + } + + set timelineChart(value) { + this.#timelineChart = value; + } + + get currentFileIndex() { + return this.#currentFileIndex; + } + + set currentFileIndex(value) { + this.#currentFileIndex = value; + } init() { Tooltip.positioners.xyFixed = function (elements, eventPosition) { @@ -41,11 +66,11 @@ export const XYAnalysis = { Legend, zoomPlugin ); - }, + } openXYModal() { const modal = document.getElementById('xyModal'); - modal.style.display = 'flex'; + if (modal) modal.style.display = 'flex'; const splitView = document.getElementById('xySplitView'); const timelineView = document.getElementById('xyTimelineView'); @@ -56,94 +81,206 @@ export const XYAnalysis = { } this.populateGlobalFileSelector(); - }, + } closeXYModal() { - document.getElementById('xyModal').style.display = 'none'; - }, + const modal = document.getElementById('xyModal'); + if (modal) modal.style.display = 'none'; + } populateGlobalFileSelector() { - const fileSelect = document.getElementById('xyGlobalFile'); - fileSelect.innerHTML = AppState.files - .map((f, i) => ``) - .join(''); - this.onFileChange(); - }, + const fileNames = AppState.files.map((f) => f.name); + + this.createSearchableSelect( + 'xyGlobalFile', + fileNames, + fileNames[this.#currentFileIndex || 0] || '', + (selectedName) => { + const idx = AppState.files.findIndex((f) => f.name === selectedName); + this.#currentFileIndex = idx; + this.onFileChange(); + } + ); + + if (AppState.files.length > 0 && this.#currentFileIndex === undefined) { + this.#currentFileIndex = 0; + this.onFileChange(); + } else if (AppState.files.length > 0) { + this.onFileChange(); + } + } onFileChange() { - const fileIdx = document.getElementById('xyGlobalFile').value; + const fileIdx = + this.#currentFileIndex !== undefined ? this.#currentFileIndex : 0; const file = AppState.files[fileIdx]; if (!file) return; - const options = file.availableSignals - .sort() - .map((s) => ``) - .join(''); + const signals = [...(file.availableSignals || [])].sort(); ['0', '1'].forEach((panelIdx) => { - document.getElementById(`xyX-${panelIdx}`).innerHTML = options; - document.getElementById(`xyY-${panelIdx}`).innerHTML = options; - document.getElementById(`xyZ-${panelIdx}`).innerHTML = options; - - if (panelIdx === '0') { - this.setSelectValue(`xyX-0`, 'Engine Rpm'); - this.setSelectValue(`xyY-0`, 'Intake Manifold Pressure'); - this.setSelectValue(`xyZ-0`, 'Air Mass'); - } else { - this.setSelectValue(`xyX-1`, 'Engine Rpm'); - this.setSelectValue(`xyY-1`, 'Air Mass Flow Measured'); - this.setSelectValue(`xyZ-1`, 'Intake Manifold Pressure'); - } + let defX = 'Engine Rpm'; + let defY = + panelIdx === '0' + ? 'Intake Manifold Pressure' + : 'Air Mass Flow Measured'; + let defZ = panelIdx === '0' ? 'Air Mass' : 'Intake Manifold Pressure'; + + const matchSignal = (search) => + signals.find((s) => s.toLowerCase().includes(search.toLowerCase())) || + signals[0]; + + this.createSearchableSelect( + `xyX-${panelIdx}`, + signals, + matchSignal(defX), + () => this.plot(panelIdx) + ); + this.createSearchableSelect( + `xyY-${panelIdx}`, + signals, + matchSignal(defY), + () => this.plot(panelIdx) + ); + this.createSearchableSelect( + `xyZ-${panelIdx}`, + signals, + matchSignal(defZ), + () => this.plot(panelIdx) + ); }); this.plot('0'); this.plot('1'); this.updateTimeline(); - }, - - setSelectValue(id, searchStr) { - const sel = document.getElementById(id); - for (let opt of sel.options) { - if (opt.value.includes(searchStr)) { - sel.value = opt.value; - break; - } + } + + createSearchableSelect(elementId, options, defaultValue, onChangeCallback) { + let container = document.getElementById(elementId); + if (!container) return; + + if (container.tagName === 'SELECT') { + const div = document.createElement('div'); + div.id = elementId; + div.className = container.className; + div.style.cssText = container.style.cssText; + div.classList.add('searchable-select-wrapper'); + container.parentNode.replaceChild(div, container); + container = div; } - }, + + container.innerHTML = ''; + if (!container.classList.contains('searchable-select-wrapper')) { + container.classList.add('searchable-select-wrapper'); + } + container.style.marginBottom = '5px'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'searchable-input'; + input.value = defaultValue || ''; + input.placeholder = 'Search...'; + input.setAttribute('data-selected-value', defaultValue || ''); + + const list = document.createElement('div'); + list.className = 'search-results-list'; + + const renderList = (filter = '') => { + list.innerHTML = ''; + const lower = filter.toLowerCase(); + const filtered = options.filter((o) => o.toLowerCase().includes(lower)); + + if (filtered.length === 0) { + const noRes = document.createElement('div'); + noRes.className = 'search-option'; + noRes.style.color = '#999'; + noRes.textContent = 'No signals found'; + list.appendChild(noRes); + } else { + filtered.forEach((opt) => { + const item = document.createElement('div'); + item.className = 'search-option'; + item.textContent = opt; + item.onclick = () => { + input.value = opt; + input.setAttribute('data-selected-value', opt); + list.style.display = 'none'; + if (onChangeCallback) onChangeCallback(opt); + }; + list.appendChild(item); + }); + } + }; + + input.onfocus = () => { + renderList(''); + list.style.display = 'block'; + }; + + input.oninput = (e) => { + renderList(e.target.value); + list.style.display = 'block'; + }; + + const closeListener = (e) => { + if (!document.body.contains(container)) { + document.removeEventListener('click', closeListener); + return; + } + if (!container.contains(e.target)) { + list.style.display = 'none'; + } + }; + document.addEventListener('click', closeListener); + + container.appendChild(input); + container.appendChild(list); + } + + getInputValue(containerId) { + const container = document.getElementById(containerId); + if (!container) return ''; + if (container.tagName === 'SELECT') return container.value; + const input = container.querySelector('input'); + return input ? input.value : ''; + } plot(panelIdx) { - const fileIdx = document.getElementById('xyGlobalFile').value; - const xSig = document.getElementById(`xyX-${panelIdx}`).value; - const ySig = document.getElementById(`xyY-${panelIdx}`).value; - const zSig = document.getElementById(`xyZ-${panelIdx}`).value; + const fileIdx = + this.#currentFileIndex !== undefined ? this.#currentFileIndex : 0; - this.renderChart(panelIdx, fileIdx, xSig, ySig, zSig); + const xSig = this.getInputValue(`xyX-${panelIdx}`); + const ySig = this.getInputValue(`xyY-${panelIdx}`); + const zSig = this.getInputValue(`xyZ-${panelIdx}`); + + if (!xSig || !ySig || !zSig) return; + this.renderChart(panelIdx, fileIdx, xSig, ySig, zSig); this.updateTimeline(); - }, + } resetAllZooms() { - this.charts.forEach((c) => c?.resetZoom()); - if (this.timelineChart) this.timelineChart.resetZoom(); - }, + this.#charts.forEach((c) => c?.resetZoom()); + if (this.#timelineChart) this.#timelineChart.resetZoom(); + } updateTimeline() { - const fileIdx = document.getElementById('xyGlobalFile').value; + const fileIdx = + this.#currentFileIndex !== undefined ? this.#currentFileIndex : 0; const signals = new Set(); ['0', '1'].forEach((idx) => { - const x = document.getElementById(`xyX-${idx}`).value; - const y = document.getElementById(`xyY-${idx}`).value; - const z = document.getElementById(`xyZ-${idx}`).value; + const x = this.getInputValue(`xyX-${idx}`); + const y = this.getInputValue(`xyY-${idx}`); + const z = this.getInputValue(`xyZ-${idx}`); if (x) signals.add(x); if (y) signals.add(y); if (z) signals.add(z); }); const uniqueSignals = Array.from(signals).filter((s) => s); - this.renderTimeline(fileIdx, uniqueSignals); - }, + } renderTimeline(fileIdx, signalNames) { const canvas = document.getElementById('xyTimelineCanvas'); @@ -152,7 +289,7 @@ export const XYAnalysis = { const ctx = canvas.getContext('2d'); const file = AppState.files[fileIdx]; - if (this.timelineChart) this.timelineChart.destroy(); + if (this.#timelineChart) this.#timelineChart.destroy(); if (!file || signalNames.length === 0) return; const datasets = signalNames @@ -171,18 +308,10 @@ export const XYAnalysis = { originalValue: parseFloat(p.y), })); - const defaultColors = [ - '#e6194b', - '#3cb44b', - '#ffe119', - '#4363d8', - '#f58231', - '#911eb4', - ]; - const color = - window.PaletteManager && PaletteManager.getColorForSignal - ? PaletteManager.getColorForSignal(0, idx) - : defaultColors[idx % defaultColors.length]; + const color = PaletteManager.getColorForSignal( + fileIdx, + (file.availableSignals || []).indexOf(sigName) + ); return { label: sigName, @@ -214,7 +343,7 @@ export const XYAnalysis = { ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); ctx.lineWidth = 1; - const isDark = document.body.classList.contains('dark-theme'); + const isDark = document.body.classList.contains('pref-theme-dark'); ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'; @@ -224,10 +353,11 @@ export const XYAnalysis = { }, }; - const isDark = document.body.classList.contains('dark-theme'); + const isDark = document.body.classList.contains('pref-theme-dark'); const textColor = isDark ? '#eee' : '#333'; + const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; - this.timelineChart = new Chart(ctx, { + this.#timelineChart = new Chart(ctx, { type: 'line', data: { datasets }, plugins: [hoverLinePlugin], @@ -245,9 +375,7 @@ export const XYAnalysis = { font: { size: 10 }, color: textColor, }, - grid: { - color: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', - }, + grid: { color: gridColor }, ticks: { font: { size: 10 }, color: textColor }, }, y: { display: false, min: -0.05, max: 1.05 }, @@ -283,14 +411,16 @@ export const XYAnalysis = { }, }, }); - }, + } renderChart(panelIdx, fileIdx, signalX, signalY, signalZ) { const canvasId = `xyCanvas-${panelIdx}`; - const ctx = document.getElementById(canvasId).getContext('2d'); + const canvas = document.getElementById(canvasId); + if (!canvas) return; + const ctx = canvas.getContext('2d'); - if (this.charts[panelIdx]) { - this.charts[panelIdx].destroy(); + if (this.#charts[panelIdx]) { + this.#charts[panelIdx].destroy(); } const data = this.generateScatterData(fileIdx, signalX, signalY, signalZ); @@ -303,11 +433,11 @@ export const XYAnalysis = { this.updateLegend(panelIdx, minZ, maxZ, signalZ); - const isDark = document.body.classList.contains('dark-theme'); + const isDark = document.body.classList.contains('pref-theme-dark'); const color = isDark ? '#eee' : '#333'; const grid = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; - this.charts[panelIdx] = new Chart(ctx, { + this.#charts[panelIdx] = new Chart(ctx, { type: 'scatter', data: { datasets: [ @@ -343,10 +473,84 @@ export const XYAnalysis = { legend: { display: false }, datalabels: { display: false }, tooltip: { - position: 'xyFixed', - callbacks: { - label: (ctx) => - `X: ${ctx.raw.x.toFixed(2)}, Y: ${ctx.raw.y.toFixed(2)}, Z: ${ctx.raw.z.toFixed(2)}`, + enabled: false, + position: 'nearest', + external: (context) => { + const { chart, tooltip } = context; + const tooltipEl = this.getOrCreateTooltip(chart); + + if (tooltip.opacity === 0) { + tooltipEl.style.opacity = 0; + return; + } + + if (tooltip.body) { + const file = AppState.files[fileIdx]; + const idxX = file.availableSignals.indexOf(signalX); + const idxY = file.availableSignals.indexOf(signalY); + const idxZ = file.availableSignals.indexOf(signalZ); + + const colorX = PaletteManager.getColorForSignal(fileIdx, idxX); + const colorY = PaletteManager.getColorForSignal(fileIdx, idxY); + const colorZ = PaletteManager.getColorForSignal(fileIdx, idxZ); + + const rawPoint = tooltip.dataPoints[0].raw; + + const tableHead = document.createElement('thead'); + const tableBody = document.createElement('tbody'); + + const makeRow = (color, label, value) => { + const tr = document.createElement('tr'); + tr.style.backgroundColor = 'inherit'; + tr.style.borderWidth = 0; + + const tdColor = document.createElement('td'); + tdColor.style.borderWidth = 0; + + const span = document.createElement('span'); + span.style.background = color; + span.style.borderColor = color; + span.style.borderWidth = '2px'; + span.style.marginRight = '10px'; + span.style.height = '10px'; + span.style.width = '10px'; + span.style.display = 'inline-block'; + span.style.borderRadius = '50%'; + + tdColor.appendChild(span); + + const tdText = document.createElement('td'); + tdText.style.borderWidth = 0; + tdText.style.color = '#fff'; + tdText.textContent = `${label}: ${value.toFixed(2)}`; + + tr.appendChild(tdColor); + tr.appendChild(tdText); + return tr; + }; + + tableBody.appendChild(makeRow(colorX, signalX, rawPoint.x)); + tableBody.appendChild(makeRow(colorY, signalY, rawPoint.y)); + tableBody.appendChild(makeRow(colorZ, signalZ, rawPoint.z)); + + const tableRoot = tooltipEl.querySelector('table'); + tableRoot.innerHTML = ''; + tableRoot.appendChild(tableHead); + tableRoot.appendChild(tableBody); + } + + const { offsetLeft: positionX, offsetTop: positionY } = + chart.canvas; + + tooltipEl.style.opacity = 1; + tooltipEl.style.left = positionX + tooltip.caretX + 'px'; + tooltipEl.style.top = positionY + tooltip.caretY + 'px'; + tooltipEl.style.font = tooltip.options.bodyFont.string; + tooltipEl.style.padding = + tooltip.options.padding + + 'px ' + + tooltip.options.padding + + 'px'; }, }, zoom: { @@ -356,7 +560,35 @@ export const XYAnalysis = { }, }, }); - }, + } + + getOrCreateTooltip(chart) { + let tooltipEl = chart.canvas.parentNode.querySelector( + 'div.chartjs-tooltip' + ); + + if (!tooltipEl) { + tooltipEl = document.createElement('div'); + tooltipEl.className = 'chartjs-tooltip'; + tooltipEl.style.background = 'rgba(0, 0, 0, 0.8)'; + tooltipEl.style.borderRadius = '3px'; + tooltipEl.style.color = 'white'; + tooltipEl.style.opacity = 1; + tooltipEl.style.pointerEvents = 'none'; + tooltipEl.style.position = 'absolute'; + tooltipEl.style.transform = 'translate(-50%, 0)'; + tooltipEl.style.transition = 'all .1s ease'; + tooltipEl.style.zIndex = 100; + + const table = document.createElement('table'); + table.style.margin = '0px'; + + tooltipEl.appendChild(table); + chart.canvas.parentNode.appendChild(tooltipEl); + } + + return tooltipEl; + } updateLegend(panelIdx, min, max, zLabel) { const legend = document.getElementById(`xyLegend-${panelIdx}`); @@ -368,7 +600,7 @@ export const XYAnalysis = { labelContainer.className = 'legend-label-container'; const labelSpan = document.createElement('span'); labelSpan.className = 'z-axis-label'; - labelSpan.innerText = zLabel || 'Z-Axis'; + labelSpan.textContent = zLabel || 'Z-Axis'; labelContainer.appendChild(labelSpan); legend.appendChild(labelContainer); @@ -383,11 +615,11 @@ export const XYAnalysis = { const pct = 1 - i / (steps - 1); const val = min + (max - min) * pct; const valSpan = document.createElement('span'); - valSpan.innerText = val.toFixed(1); + valSpan.textContent = val.toFixed(1); valuesContainer.appendChild(valSpan); } legend.appendChild(valuesContainer); - }, + } generateScatterData(fileIndex, signalXName, signalYName, signalZName) { const file = AppState.files[fileIndex]; @@ -435,7 +667,7 @@ export const XYAnalysis = { } }); return scatterPoints; - }, + } getHeatColor(value, min, max) { if (min === max) return 'hsla(240, 100%, 50%, 0.8)'; @@ -443,5 +675,7 @@ export const XYAnalysis = { ratio = Math.max(0, Math.min(1, ratio)); const hue = (1 - ratio) * 240; return `hsla(${hue}, 100%, 50%, 0.8)`; - }, -}; + } +} + +export const XYAnalysis = new XYAnalysisClass(); diff --git a/tests/mathchannels.test.js b/tests/mathchannels.test.js index e0af074..3b1ac41 100644 --- a/tests/mathchannels.test.js +++ b/tests/mathchannels.test.js @@ -392,34 +392,30 @@ describe('MathChannels', () => { }, ]; - // Open modal first to populate formula select window.openMathModal(); - const select = document.getElementById('mathFormulaSelect'); - // Find ID for "multiply_const" (Signal * Factor) - // We assume definitions order or check values. - // Let's iterate options to find 'multiply_const' let targetVal = ''; for (let opt of select.options) { if (opt.value === 'multiply_const') targetVal = opt.value; } select.value = targetVal; - // Trigger change window.onMathFormulaChange(); const container = document.getElementById('mathInputsContainer'); - const inputs = container.querySelectorAll('.template-select'); + // Changed to find both searchable inputs and constant inputs + const inputs = container.querySelectorAll( + '.template-select, .searchable-input' + ); - // multiply_const has 2 inputs: Source (select) and Factor (number) expect(inputs.length).toBe(2); - expect(inputs[0].tagName).toBe('SELECT'); // Signal selector - expect(inputs[1].tagName).toBe('INPUT'); // Constant input - expect(inputs[1].type).toBe('number'); + // Input 0 (Source) is now a text input (Searchable Select) + expect(inputs[0].tagName).toBe('INPUT'); + expect(inputs[0].type).toBe('text'); - // Check auto-name generation - const nameInput = document.getElementById('mathChannelName'); - expect(nameInput.value).toContain('Math:'); + // Input 1 (Constant) remains a number input + expect(inputs[1].tagName).toBe('INPUT'); + expect(inputs[1].type).toBe('number'); }); test('createMathChannel (executeCreation) validates form and calls createChannel', () => { diff --git a/tests/xyanalysis.suite.1.test.js b/tests/xyanalysis.suite.1.test.js deleted file mode 100644 index 9a5c3a4..0000000 --- a/tests/xyanalysis.suite.1.test.js +++ /dev/null @@ -1,532 +0,0 @@ -import { - jest, - describe, - test, - expect, - beforeEach, - afterEach, -} from '@jest/globals'; - -// 1. Define mock instances outside to be reused -const mockChartInstance = { - destroy: jest.fn(), - update: jest.fn(), - draw: jest.fn(), - resetZoom: jest.fn(), - width: 1000, - scales: { x: { min: 0, max: 1000, getValueForPixel: jest.fn() } }, - data: { datasets: [] }, - options: { - plugins: { datalabels: {}, zoom: {}, tooltip: { callbacks: {} } }, - scales: { x: { min: 0, max: 0, title: {} }, y: { title: {} } }, - }, -}; - -// 2. Mock Chart.js with specific Tooltip support to prevent init() crashes -await jest.unstable_mockModule('chart.js', () => { - const MockChart = jest.fn(() => mockChartInstance); - MockChart.register = jest.fn(); - - const MockTooltip = jest.fn(); - MockTooltip.positioners = {}; - - return { - __esModule: true, - Chart: MockChart, - ScatterController: jest.fn(), - LineController: jest.fn(), - PointElement: jest.fn(), - LineElement: jest.fn(), - LinearScale: jest.fn(), - TimeScale: jest.fn(), - Legend: jest.fn(), - Tooltip: MockTooltip, - _adapters: { _date: {} }, - }; -}); - -await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); - -const mockZoomPlugin = { id: 'zoom' }; -await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ - default: mockZoomPlugin, -})); - -await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); -await jest.unstable_mockModule('../src/config.js', () => ({ - AppState: { files: [] }, -})); - -await jest.unstable_mockModule('../src/palettemanager.js', () => ({ - PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, -})); - -// Variables for fresh module instances per test -let XYAnalysis; -let AppState; -let Chart; - -describe('XYAnalysis Controller', () => { - beforeEach(async () => { - // 3. Reset modules to clear singleton state (Crucial for 'init' tests) - jest.resetModules(); - jest.clearAllMocks(); - - // 4. Setup DOM *before* importing modules so top-level lookups find elements - document.body.innerHTML = ` - - - - - - - - - - - - - - - - - `; - - jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ - canvas: document.createElement('canvas'), - save: jest.fn(), - restore: jest.fn(), - fillRect: jest.fn(), - measureText: jest.fn(() => ({ width: 0 })), - }); - - // 5. Dynamic imports to ensure a fresh XYAnalysis instance - const xyModule = await import('../src/xyanalysis.js'); - XYAnalysis = xyModule.XYAnalysis; - - const configModule = await import('../src/config.js'); - AppState = configModule.AppState; - AppState.files = []; - - const chartModule = await import('chart.js'); - Chart = chartModule.Chart; - - if (XYAnalysis.charts) XYAnalysis.charts = [null, null]; - if (XYAnalysis.timelineChart) XYAnalysis.timelineChart = null; - - Chart.mockImplementation(() => mockChartInstance); - delete window.PaletteManager; - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('UI Interaction', () => { - test('init registers Chart.js plugins', () => { - XYAnalysis.init(); - expect(Chart.register).toHaveBeenCalled(); - }); - - test('openXYModal shows modal and triggers file population', () => { - const modal = document.getElementById('xyModal'); - const spy = jest.spyOn(XYAnalysis, 'populateGlobalFileSelector'); - - XYAnalysis.openXYModal(); - - expect(modal.style.display).toBe('flex'); - expect(spy).toHaveBeenCalled(); - }); - - test('closeXYModal hides modal', () => { - const modal = document.getElementById('xyModal'); - modal.style.display = 'flex'; - - XYAnalysis.closeXYModal(); - - expect(modal.style.display).toBe('none'); - }); - - test('populateGlobalFileSelector fills dropdown and triggers change', () => { - // Prevent crash by providing 'signals' object - AppState.files = [ - { name: 'Trip A', availableSignals: [], signals: {} }, - { name: 'Trip B', availableSignals: [], signals: {} }, - ]; - const spy = jest.spyOn(XYAnalysis, 'onFileChange'); - - XYAnalysis.populateGlobalFileSelector(); - - const select = document.getElementById('xyGlobalFile'); - expect(select.children.length).toBe(2); - expect(select.children[0].text).toBe('Trip A'); - expect(spy).toHaveBeenCalled(); - }); - - test('onFileChange populates axis selectors and sets defaults', () => { - AppState.files = [ - { - name: 'Trip A', - availableSignals: [ - 'Engine Rpm', - 'Intake Manifold Pressure', - 'Air Mass', - ], - signals: { - 'Engine Rpm': [], - 'Intake Manifold Pressure': [], - 'Air Mass': [], - }, - }, - ]; - - const globalSel = document.getElementById('xyGlobalFile'); - globalSel.innerHTML = ''; - globalSel.value = '0'; - - const updateTimelineSpy = jest.spyOn(XYAnalysis, 'updateTimeline'); - - XYAnalysis.onFileChange(); - - expect(document.getElementById('xyX-0').value).toBe('Engine Rpm'); - expect(document.getElementById('xyY-0').value).toBe( - 'Intake Manifold Pressure' - ); - expect(updateTimelineSpy).toHaveBeenCalled(); - }); - - test('onFileChange handles missing file gracefully', () => { - AppState.files = []; - document.getElementById('xyGlobalFile').value = '0'; - - expect(() => XYAnalysis.onFileChange()).not.toThrow(); - }); - - test('setSelectValue selects option if partial match found', () => { - const select = document.getElementById('xyX-0'); - select.innerHTML = ''; - - XYAnalysis.setSelectValue('xyX-0', 'Signal Name'); - expect(select.value).toBe('Some Long Signal Name'); - }); - - test('setSelectValue does nothing if no match found', () => { - const select = document.getElementById('xyX-0'); - select.innerHTML = ''; - select.value = 'A'; - - XYAnalysis.setSelectValue('xyX-0', 'Z'); - expect(select.value).toBe('A'); - }); - }); - - describe('Legend Logic', () => { - test('updateLegend handles constant values correctly', () => { - XYAnalysis.updateLegend('0', 10, 10, 'Constant'); - const legend = document.getElementById('xyLegend-0'); - const values = legend.querySelectorAll('.legend-values span'); - expect(values[0].innerText).toBe('10.0'); - expect(values[4].innerText).toBe('10.0'); - }); - - test('updateLegend creates correct gradient structure', () => { - XYAnalysis.updateLegend('0', 0, 100, 'Label'); - const legend = document.getElementById('xyLegend-0'); - expect(legend.querySelector('.gradient-bar')).not.toBeNull(); - expect(legend.querySelector('.z-axis-label').innerText).toBe('Label'); - }); - - test('updateLegend returns early if element missing', () => { - document.getElementById('xyLegend-0').remove(); - expect(() => XYAnalysis.updateLegend('0', 0, 10, 'L')).not.toThrow(); - }); - }); - - describe('Data Processing', () => { - test('generateScatterData handles millisecond timestamp tolerance', () => { - AppState.files = [ - { - signals: { - X: [{ x: 200000, y: 1 }], - Y: [{ x: 200100, y: 2 }], - Z: [{ x: 200499, y: 3 }], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ x: 1, y: 2, z: 3 }); - }); - - test('generateScatterData skips points outside tolerance', () => { - AppState.files = [ - { - signals: { - X: [{ x: 1.0, y: 1 }], - Y: [{ x: 2.0, y: 2 }], - Z: [{ x: 1.0, y: 3 }], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - expect(result).toHaveLength(0); - }); - - test('generateScatterData synchronizes lagging signals', () => { - AppState.files = [ - { - signals: { - X: [{ x: 50.0, y: 100 }], - Y: [ - { x: 1.0, y: 1 }, - { x: 2.0, y: 2 }, - { x: 50.0, y: 10 }, - ], - Z: [ - { x: 5.0, y: 5 }, - { x: 50.0, y: 20 }, - ], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ x: 100, y: 10, z: 20 }); - }); - - test('getHeatColor returns default blue when min equals max', () => { - const color = XYAnalysis.getHeatColor(10, 10, 10); - expect(color).toBe('hsla(240, 100%, 50%, 0.8)'); - }); - }); - - describe('Chart Rendering', () => { - test('renderChart handles empty data gracefully', () => { - jest.spyOn(XYAnalysis, 'generateScatterData').mockReturnValue([]); - XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); - expect(Chart).toHaveBeenCalledTimes(0); - }); - - test('resetAllZooms resets scatter and timeline charts', () => { - XYAnalysis.charts = [mockChartInstance, null]; - XYAnalysis.timelineChart = mockChartInstance; - XYAnalysis.resetAllZooms(); - expect(mockChartInstance.resetZoom).toHaveBeenCalledTimes(2); - }); - - test('Scatter tooltip callback formats values correctly', () => { - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 2, z: 3 }]); - - XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); - - const config = Chart.mock.calls[0][1]; - const callback = config.options.plugins.tooltip.callbacks.label; - const context = { raw: { x: 1.111, y: 2.222, z: 3.333 } }; - - const text = callback(context); - - expect(text).toContain('X: 1.11'); - expect(text).toContain('Y: 2.22'); - expect(text).toContain('Z: 3.33'); - }); - }); - - describe('Timeline Integration', () => { - test('renderTimeline creates chart with normalized data', () => { - AppState.files = [ - { - startTime: 1000, - signals: { - RPM: [ - { x: 1000, y: 0 }, - { x: 2000, y: 6000 }, - ], - Boost: [ - { x: 1000, y: 0 }, - { x: 2000, y: 1.5 }, - ], - }, - }, - ]; - - XYAnalysis.renderTimeline(0, ['RPM', 'Boost']); - - expect(Chart).toHaveBeenCalled(); - const config = Chart.mock.calls[0][1]; - const datasets = config.data.datasets; - - expect(datasets.length).toBe(2); - - const rpmData = datasets.find((d) => d.label === 'RPM').data; - expect(rpmData[0].y).toBeCloseTo(0); - expect(rpmData[1].y).toBeCloseTo(1); - }); - - test('Timeline tooltip returns original values', () => { - AppState.files = [{ startTime: 0, signals: { S1: [{ x: 0, y: 50 }] } }]; - XYAnalysis.renderTimeline(0, ['S1']); - - const config = Chart.mock.calls[0][1]; - const callback = config.options.plugins.tooltip.callbacks.label; - const context = { - dataset: { label: 'S1' }, - raw: { originalValue: 50.1234 }, - }; - - expect(callback(context)).toBe('S1: 50.12'); - }); - - test('renderTimeline uses PaletteManager module color', () => { - AppState.files = [{ startTime: 0, signals: { SigA: [{ x: 0, y: 0 }] } }]; - window.PaletteManager = { getColorForSignal: jest.fn(() => '#123456') }; - - XYAnalysis.renderTimeline(0, ['SigA']); - - const config = Chart.mock.calls[0][1]; - expect(config.data.datasets[0].borderColor).toBe('#ff0000'); - }); - - test('renderTimeline handles flatline signals', () => { - AppState.files = [ - { - startTime: 0, - signals: { - Flat: [ - { x: 0, y: 100 }, - { x: 1, y: 100 }, - ], - }, - }, - ]; - - XYAnalysis.renderTimeline(0, ['Flat']); - - const config = Chart.mock.calls[0][1]; - const data = config.data.datasets[0].data; - expect(data[0].y).toBe(0); - }); - - test('renderTimeline returns early if canvas missing', () => { - document.getElementById('xyTimelineCanvas').remove(); - XYAnalysis.renderTimeline(0, ['RPM']); - expect(Chart).not.toHaveBeenCalled(); - }); - - test('updateTimeline aggregates signals and calls render', () => { - const setVal = (id, val) => { - const el = document.getElementById(id); - el.innerHTML = ``; - el.value = val; - }; - setVal('xyGlobalFile', '0'); - setVal('xyX-0', 'S1'); - setVal('xyY-0', 'S2'); - setVal('xyZ-0', 'S1'); - setVal('xyX-1', 'S3'); - setVal('xyY-1', ''); - setVal('xyZ-1', ''); - - AppState.files = [{ startTime: 0, signals: { S1: [], S2: [], S3: [] } }]; - - const spy = jest.spyOn(XYAnalysis, 'renderTimeline'); - - XYAnalysis.updateTimeline(); - - expect(spy).toHaveBeenCalledWith( - expect.anything(), - expect.arrayContaining(['S1', 'S2', 'S3']) - ); - }); - - test('plot triggers chart render and timeline update', () => { - const scatterSpy = jest - .spyOn(XYAnalysis, 'renderChart') - .mockImplementation(() => {}); - const timelineSpy = jest - .spyOn(XYAnalysis, 'updateTimeline') - .mockImplementation(() => {}); - - const setVal = (id, val) => { - const el = document.getElementById(id); - el.innerHTML = ``; - el.value = val; - }; - - document.getElementById('xyGlobalFile').innerHTML = - ''; - document.getElementById('xyGlobalFile').value = '0'; - setVal('xyX-0', 'A'); - setVal('xyY-0', 'B'); - setVal('xyZ-0', 'C'); - - AppState.files = [{ name: 'F', startTime: 0, signals: {} }]; - - XYAnalysis.plot('0'); - - expect(scatterSpy).toHaveBeenCalled(); - expect(timelineSpy).toHaveBeenCalled(); - }); - }); - - describe('Extended Coverage', () => { - test('getHeatColor generates correct gradient range', () => { - expect(XYAnalysis.getHeatColor(0, 0, 100)).toContain('240'); // Blue - expect(XYAnalysis.getHeatColor(100, 0, 100)).toContain('0'); // Red - expect(XYAnalysis.getHeatColor(50, 0, 100)).toContain('120'); // Green - }); - - test('renderChart configures Axis Titles and Zoom options', () => { - AppState.files = [ - { - signals: { - X: [{ x: 1, y: 1 }], - Y: [{ x: 1, y: 1 }], - Z: [{ x: 1, y: 1 }], - }, - }, - ]; - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 1, z: 1 }]); - - XYAnalysis.renderChart('0', 0, 'X', 'Y', 'Z'); - - expect(Chart).toHaveBeenCalled(); - const config = Chart.mock.calls[0][1]; - - expect(config.options.scales.x.title.text).toBe('X'); - expect(config.options.scales.y.title.text).toBe('Y'); - expect(config.options.plugins.zoom.zoom.wheel.enabled).toBe(true); - }); - - test('generateScatterData handles partial overlap', () => { - AppState.files = [ - { - signals: { - X: [ - { x: 100, y: 1 }, - { x: 200, y: 2 }, - { x: 300, y: 3 }, - ], - Y: [{ x: 200, y: 20 }], - Z: [{ x: 200, y: 30 }], - }, - }, - ]; - - const data = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - - expect(data).toHaveLength(1); - expect(data[0].x).toBe(2); - expect(data[0].y).toBe(20); - }); - }); -}); diff --git a/tests/xyanalysis.suite.2.test.js b/tests/xyanalysis.suite.2.test.js deleted file mode 100644 index a0bb384..0000000 --- a/tests/xyanalysis.suite.2.test.js +++ /dev/null @@ -1,265 +0,0 @@ -import { - jest, - describe, - test, - expect, - beforeEach, - afterEach, -} from '@jest/globals'; - -const mockChartInstance = { - destroy: jest.fn(), - update: jest.fn(), - draw: jest.fn(), - resetZoom: jest.fn(), - width: 1000, - scales: { x: { min: 0, max: 1000, getValueForPixel: jest.fn() } }, - data: { datasets: [] }, - options: { - plugins: { datalabels: {}, zoom: {}, tooltip: { callbacks: {} } }, - scales: { x: { min: 0, max: 0 } }, - }, -}; - -await jest.unstable_mockModule('chart.js', () => { - const MockChart = jest.fn(() => mockChartInstance); - MockChart.register = jest.fn(); - return { - __esModule: true, - Chart: MockChart, - ScatterController: jest.fn(), - LineController: jest.fn(), - PointElement: jest.fn(), - LineElement: jest.fn(), - LinearScale: jest.fn(), - TimeScale: jest.fn(), - Legend: jest.fn(), - Tooltip: jest.fn(), - _adapters: { _date: {} }, - }; -}); - -await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); -const mockZoomPlugin = { id: 'zoom' }; -await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ - default: mockZoomPlugin, -})); - -await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); -await jest.unstable_mockModule('../src/config.js', () => ({ - AppState: { files: [] }, -})); - -await jest.unstable_mockModule('../src/palettemanager.js', () => ({ - PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, -})); - -const { XYAnalysis } = await import('../src/xyanalysis.js'); -const { AppState } = await import('../src/config.js'); -const { Chart } = await import('chart.js'); - -describe('XYAnalysis Comprehensive Coverage', () => { - beforeEach(() => { - jest.clearAllMocks(); - AppState.files = []; - XYAnalysis.charts = [null, null]; - XYAnalysis.timelineChart = null; - - Chart.mockImplementation(() => mockChartInstance); - - jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ - canvas: document.createElement('canvas'), - save: jest.fn(), - restore: jest.fn(), - fillRect: jest.fn(), - measureText: jest.fn(() => ({ width: 0 })), - }); - - document.body.innerHTML = ` - - - - - - - - - - - - - - - - - `; - - delete window.PaletteManager; - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('Legend Logic', () => { - test('updateLegend() handles min=max edge case (Constant Value)', () => { - XYAnalysis.updateLegend('0', 10, 10, 'Constant'); - const legend = document.getElementById('xyLegend-0'); - const values = legend.querySelectorAll('.legend-values span'); - expect(values[0].innerText).toBe('10.0'); - expect(values[4].innerText).toBe('10.0'); - }); - - test('updateLegend() creates correct gradient structure', () => { - XYAnalysis.updateLegend('0', 0, 100, 'Label'); - const legend = document.getElementById('xyLegend-0'); - expect(legend.querySelector('.gradient-bar')).not.toBeNull(); - expect(legend.querySelector('.z-axis-label').innerText).toBe('Label'); - }); - }); - - describe('Data Synchronization (generateScatterData)', () => { - test('Handles Millisecond Timestamps (Tolerance Logic)', () => { - AppState.files = [ - { - signals: { - X: [{ x: 200000, y: 1 }], - Y: [{ x: 200100, y: 2 }], - Z: [{ x: 200499, y: 3 }], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ x: 1, y: 2, z: 3 }); - }); - - test('Skips points outside Tolerance', () => { - AppState.files = [ - { - signals: { - X: [{ x: 1.0, y: 1 }], - Y: [{ x: 2.0, y: 2 }], - Z: [{ x: 1.0, y: 3 }], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - expect(result).toHaveLength(0); - }); - - test('Iterates "while" loops when Y/Z lag behind X', () => { - AppState.files = [ - { - signals: { - X: [{ x: 50.0, y: 100 }], - Y: [ - { x: 1.0, y: 1 }, - { x: 2.0, y: 2 }, - { x: 50.0, y: 10 }, - ], - Z: [ - { x: 5.0, y: 5 }, - { x: 50.0, y: 20 }, - ], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ x: 100, y: 10, z: 20 }); - }); - - test('getHeatColor handles min == max edge case', () => { - const color = XYAnalysis.getHeatColor(10, 10, 10); - expect(color).toBe('hsla(240, 100%, 50%, 0.8)'); - }); - }); - - describe('Timeline Rendering', () => { - test('renderTimeline() skips signals not found in file', () => { - AppState.files = [{ startTime: 0, signals: { A: [{ x: 0, y: 0 }] } }]; - - XYAnalysis.renderTimeline(0, ['A', 'B']); - - expect(Chart).toHaveBeenCalled(); - const config = Chart.mock.calls[0][1]; - expect(config.data.datasets).toHaveLength(1); - expect(config.data.datasets[0].label).toBe('A'); - }); - - test('renderTimeline() handles Min=Max normalization (avoid divide-by-zero)', () => { - AppState.files = [ - { - startTime: 0, - signals: { - Flat: [ - { x: 0, y: 100 }, - { x: 1, y: 100 }, - ], - }, - }, - ]; - - XYAnalysis.renderTimeline(0, ['Flat']); - - const config = Chart.mock.calls[0][1]; - const data = config.data.datasets[0].data; - - expect(data[0].y).toBe(0); - }); - - test('Tooltip Callback execution (Timeline)', () => { - AppState.files = [{ startTime: 0, signals: { S1: [{ x: 0, y: 50 }] } }]; - XYAnalysis.renderTimeline(0, ['S1']); - - const config = Chart.mock.calls[0][1]; - const callback = config.options.plugins.tooltip.callbacks.label; - - const context = { - dataset: { label: 'S1' }, - raw: { originalValue: 50.1234 }, - }; - const text = callback(context); - - expect(text).toBe('S1: 50.12'); - }); - - test('Color Logic: Uses window.PaletteManager if present', () => { - AppState.files = [{ startTime: 0, signals: { S1: [{ x: 0, y: 0 }] } }]; - - window.PaletteManager = { getColorForSignal: jest.fn(() => '#ABCDEF') }; - - XYAnalysis.renderTimeline(0, ['S1']); - - const config = Chart.mock.calls[0][1]; - expect(config.data.datasets[0].borderColor).toBe('#ff0000'); - }); - }); - - describe('Scatter Plot Interaction', () => { - test('Tooltip Callback execution (Scatter)', () => { - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 2, z: 3 }]); - - XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); - - const config = Chart.mock.calls[0][1]; - const callback = config.options.plugins.tooltip.callbacks.label; - - const context = { raw: { x: 1.111, y: 2.222, z: 3.333 } }; - const text = callback(context); - - expect(text).toContain('X: 1.11'); - expect(text).toContain('Y: 2.22'); - expect(text).toContain('Z: 3.33'); - }); - }); -}); diff --git a/tests/xyanalysis.test.js b/tests/xyanalysis.test.js new file mode 100644 index 0000000..99804bd --- /dev/null +++ b/tests/xyanalysis.test.js @@ -0,0 +1,799 @@ +import { + jest, + describe, + test, + expect, + beforeEach, + afterEach, +} from '@jest/globals'; + +const mockChartInstance = { + destroy: jest.fn(), + update: jest.fn(), + resetZoom: jest.fn(), + draw: jest.fn(), + width: 1000, + scales: { x: { min: 0, max: 1000, getValueForPixel: jest.fn() } }, + data: { datasets: [] }, + canvas: { + parentNode: { + querySelector: jest.fn(), + appendChild: jest.fn(), + }, + offsetLeft: 0, + offsetTop: 0, + }, + options: { + plugins: { + datalabels: {}, + zoom: {}, + tooltip: { callbacks: {}, external: null }, + }, + scales: { x: { min: 0, max: 0, title: {} }, y: { title: {} } }, + }, +}; + +await jest.unstable_mockModule('chart.js', () => { + const MockChart = jest.fn(() => mockChartInstance); + MockChart.register = jest.fn(); + const MockTooltip = jest.fn(); + MockTooltip.positioners = {}; + return { + __esModule: true, + Chart: MockChart, + ScatterController: jest.fn(), + LineController: jest.fn(), + PointElement: jest.fn(), + LineElement: jest.fn(), + LinearScale: jest.fn(), + TimeScale: jest.fn(), + Legend: jest.fn(), + Tooltip: MockTooltip, + _adapters: { _date: {} }, + }; +}); + +await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); +await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ + default: { id: 'zoom' }, +})); +await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); +await jest.unstable_mockModule('../src/config.js', () => ({ + AppState: { files: [] }, +})); +await jest.unstable_mockModule('../src/palettemanager.js', () => ({ + PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, +})); + +const { XYAnalysis } = await import('../src/xyanalysis.js'); +const { AppState } = await import('../src/config.js'); +const { Chart, Tooltip } = await import('chart.js'); + +describe('XYAnalysis Comprehensive Tests', () => { + const originalCreateElement = document.createElement.bind(document); + + beforeEach(() => { + jest.clearAllMocks(); + AppState.files = []; + XYAnalysis.charts = [null, null]; + XYAnalysis.timelineChart = null; + XYAnalysis.currentFileIndex = undefined; + + jest.spyOn(document, 'createElement').mockImplementation((tagName) => { + const el = originalCreateElement(tagName); + Object.defineProperty(el, 'innerText', { + get() { + return this.textContent; + }, + set(value) { + this.textContent = value; + }, + configurable: true, + }); + return el; + }); + + document.body.innerHTML = ` + +
+
+ +
+
+ + + +
+ + + + + `; + + HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ + save: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + fill: jest.fn(), + fillRect: jest.fn(), + measureText: jest.fn(() => ({ width: 0 })), + })); + + Chart.mockImplementation(() => mockChartInstance); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const getInputValue = (id) => { + const container = document.getElementById(id); + return container && container.querySelector('input') + ? container.querySelector('input').value + : ''; + }; + + describe('Initialization', () => { + test('init registers Chart.js plugins', () => { + XYAnalysis.init(); + expect(Chart.register).toHaveBeenCalled(); + }); + + test('xyFixed positioner returns correct coordinates', () => { + XYAnalysis.init(); + const positioner = Tooltip.positioners.xyFixed; + const context = { chart: { chartArea: { top: 50, right: 500 } } }; + const pos = positioner.call(context, [], {}); + expect(pos).toEqual({ x: 490, y: 60 }); + }); + + test('xyFixed returns undefined if no chart', () => { + XYAnalysis.init(); + const positioner = Tooltip.positioners.xyFixed; + const pos = positioner.call({}, [], {}); + expect(pos).toBeUndefined(); + }); + }); + + describe('UI Interaction', () => { + test('openXYModal shows modal and adjusts split view styles', () => { + const modal = document.getElementById('xyModal'); + const split = document.getElementById('xySplitView'); + const timeline = document.getElementById('xyTimelineView'); + const spy = jest.spyOn(XYAnalysis, 'populateGlobalFileSelector'); + + XYAnalysis.openXYModal(); + + expect(modal.style.display).toBe('flex'); + expect(split.style.flex).toMatch(/^3/); + expect(timeline.style.flex).toMatch(/^1/); + expect(spy).toHaveBeenCalled(); + }); + + test('closeXYModal hides modal', () => { + const modal = document.getElementById('xyModal'); + modal.style.display = 'flex'; + XYAnalysis.closeXYModal(); + expect(modal.style.display).toBe('none'); + }); + + describe('Searchable Select Logic', () => { + test('Replaces SELECT element with custom DIV wrapper', () => { + const container = document.getElementById('xyX-0'); + container.outerHTML = + ''; + + XYAnalysis.createSearchableSelect('xyX-0', ['A', 'B'], 'A', jest.fn()); + + const newEl = document.getElementById('xyX-0'); + expect(newEl.tagName).toBe('DIV'); + expect(newEl.className).toContain('searchable-select-wrapper'); + expect(newEl.style.color).toBe('red'); + }); + + test('Focusing input shows all options regardless of current value', () => { + const options = ['Engine Rpm', 'Speed', 'Boost']; + const defaultValue = 'Speed'; + + XYAnalysis.createSearchableSelect( + 'xyGlobalFile', + options, + defaultValue, + jest.fn() + ); + + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + expect(input.value).toBe('Speed'); + + input.focus(); + + expect(list.children.length).toBe(3); + expect(list.children[0].textContent).toBe('Engine Rpm'); + expect(list.children[1].textContent).toBe('Speed'); + expect(list.children[2].textContent).toBe('Boost'); + expect(list.style.display).toBe('block'); + }); + + test('Filter logic shows "No signals found"', () => { + XYAnalysis.createSearchableSelect( + 'xyGlobalFile', + ['OptionA'], + '', + jest.fn() + ); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.value = 'XYZ'; + input.dispatchEvent(new Event('input')); + + expect(list.children.length).toBe(1); + expect(list.children[0].innerText).toBe('No signals found'); + }); + + test('Clicking option updates value and hides list', () => { + const cb = jest.fn(); + XYAnalysis.createSearchableSelect('xyGlobalFile', ['OptionA'], '', cb); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.focus(); + const option = list.children[0]; + option.click(); + + expect(input.value).toBe('OptionA'); + expect(list.style.display).toBe('none'); + expect(cb).toHaveBeenCalledWith('OptionA'); + }); + + test('Clicking outside closes list', () => { + XYAnalysis.createSearchableSelect('xyGlobalFile', ['A'], '', jest.fn()); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.focus(); + expect(list.style.display).toBe('block'); + document.body.click(); + expect(list.style.display).toBe('none'); + }); + + test('getInputValue handles missing input gracefully', () => { + document.getElementById('xyX-0').innerHTML = '
Broken
'; + const val = XYAnalysis.getInputValue('xyX-0'); + expect(val).toBe(''); + }); + + test('getInputValue handles raw SELECT element', () => { + const container = document.getElementById('xyX-0'); + container.outerHTML = + ''; + const val = XYAnalysis.getInputValue('xyX-0'); + expect(val).toBe('A'); + }); + }); + + test('populateGlobalFileSelector fills searchable list and triggers change', () => { + AppState.files = [{ name: 'Trip A', availableSignals: [], signals: {} }]; + const spy = jest.spyOn(XYAnalysis, 'onFileChange'); + + XYAnalysis.populateGlobalFileSelector(); + + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + expect(input.value).toBe('Trip A'); + + input.value = ''; + input.dispatchEvent(new Event('input')); + + expect(list.children[0].innerText).toBe('Trip A'); + expect(spy).toHaveBeenCalled(); + }); + + test('onFileChange populates axis selectors with defaults', () => { + AppState.files = [ + { + name: 'Trip A', + availableSignals: [ + 'Engine Rpm', + 'Intake Manifold Pressure', + 'Air Mass', + ], + signals: { + 'Engine Rpm': [], + 'Intake Manifold Pressure': [], + 'Air Mass': [], + }, + }, + ]; + XYAnalysis.populateGlobalFileSelector(); + const updateTimelineSpy = jest.spyOn(XYAnalysis, 'updateTimeline'); + + XYAnalysis.onFileChange(); + + expect(getInputValue('xyX-0')).toBe('Engine Rpm'); + expect(getInputValue('xyY-0')).toBe('Intake Manifold Pressure'); + expect(updateTimelineSpy).toHaveBeenCalled(); + }); + + test('onFileChange handles missing file gracefully', () => { + AppState.files = []; + XYAnalysis.currentFileIndex = 0; + expect(() => XYAnalysis.onFileChange()).not.toThrow(); + }); + }); + + describe('Data Processing', () => { + test('generateScatterData handles millisecond timestamp tolerance', () => { + AppState.files = [ + { + signals: { + X: [{ x: 200000, y: 1 }], + Y: [{ x: 200100, y: 2 }], + Z: [{ x: 200499, y: 3 }], + }, + }, + ]; + const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ x: 1, y: 2, z: 3 }); + }); + + test('generateScatterData skips points outside tolerance', () => { + AppState.files = [ + { + signals: { + X: [{ x: 1.0, y: 1 }], + Y: [{ x: 2.0, y: 2 }], + Z: [{ x: 1.0, y: 3 }], + }, + }, + ]; + const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); + expect(result).toHaveLength(0); + }); + + test('generateScatterData synchronizes lagging signals', () => { + AppState.files = [ + { + signals: { + X: [{ x: 50.0, y: 100 }], + Y: [ + { x: 1.0, y: 1 }, + { x: 2.0, y: 2 }, + { x: 50.0, y: 10 }, + ], + Z: [ + { x: 5.0, y: 5 }, + { x: 50.0, y: 20 }, + ], + }, + }, + ]; + const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); + expect(result[0]).toEqual({ x: 100, y: 10, z: 20 }); + }); + + test('getHeatColor gradient checks', () => { + expect(XYAnalysis.getHeatColor(10, 10, 10)).toBe( + 'hsla(240, 100%, 50%, 0.8)' + ); + expect(XYAnalysis.getHeatColor(0, 0, 100)).toContain('240'); + expect(XYAnalysis.getHeatColor(100, 0, 100)).toContain('0'); + }); + + describe('Legend Logic', () => { + test('updateLegend handles constant values', () => { + XYAnalysis.updateLegend('0', 10, 10, 'Constant'); + const legend = document.getElementById('xyLegend-0'); + const values = legend.querySelectorAll('.legend-values span'); + expect(values[0].innerText).toBe('10.0'); + expect(values[4].innerText).toBe('10.0'); + }); + + test('updateLegend creates gradient structure', () => { + XYAnalysis.updateLegend('0', 0, 100, 'Label'); + const legend = document.getElementById('xyLegend-0'); + expect(legend.querySelector('.gradient-bar')).not.toBeNull(); + expect(legend.querySelector('.z-axis-label').innerText).toBe('Label'); + }); + + test('updateLegend returns early if element missing', () => { + document.getElementById('xyLegend-0').remove(); + expect(() => XYAnalysis.updateLegend('0', 0, 10, 'L')).not.toThrow(); + }); + }); + }); + + describe('Scatter Chart Rendering', () => { + test('renderChart handles empty data gracefully', () => { + jest.spyOn(XYAnalysis, 'generateScatterData').mockReturnValue([]); + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + expect(Chart).toHaveBeenCalledTimes(0); + }); + + test('renderChart configures Axis Titles and Zoom', () => { + AppState.files = [{ availableSignals: ['X', 'Y', 'Z'], signals: {} }]; + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + + XYAnalysis.renderChart('0', 0, 'X', 'Y', 'Z'); + + const config = Chart.mock.calls[0][1]; + expect(config.options.scales.x.title.text).toBe('X'); + expect(config.options.scales.y.title.text).toBe('Y'); + expect(config.options.plugins.zoom.zoom.wheel.enabled).toBe(true); + }); + + test('Scatter chart uses external tooltip handler', () => { + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 2, z: 3 }]); + AppState.files = [{ availableSignals: ['A', 'B', 'C'], signals: {} }]; + + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + const config = Chart.mock.calls[0][1]; + const tooltipConfig = config.options.plugins.tooltip; + expect(tooltipConfig.enabled).toBe(false); + expect(typeof tooltipConfig.external).toBe('function'); + }); + + test('Custom Tooltip: Renders HTML correctly', () => { + AppState.files = [ + { availableSignals: ['RPM', 'MAP', 'MAF'], signals: {} }, + ]; + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + + XYAnalysis.renderChart('0', 0, 'RPM', 'MAP', 'MAF'); + + const config = Chart.mock.calls[0][1]; + const externalHandler = config.options.plugins.tooltip.external; + + const mockTooltipEl = document.createElement('div'); + mockTooltipEl.className = 'chartjs-tooltip'; + const mockTable = document.createElement('table'); + mockTooltipEl.appendChild(mockTable); + document.body.appendChild(mockTooltipEl); + + const mockChart = { + canvas: { + parentNode: { querySelector: jest.fn(() => mockTooltipEl) }, + offsetLeft: 10, + offsetTop: 20, + }, + }; + + const context = { + chart: mockChart, + tooltip: { + opacity: 1, + caretX: 100, + caretY: 100, + options: { padding: 6, bodyFont: { string: '12px Arial' } }, + body: [{}], + dataPoints: [{ raw: { x: 1000, y: 1.5, z: 20.5 } }], + }, + }; + + externalHandler(context); + + const rows = mockTable.querySelectorAll('tr'); + expect(rows.length).toBe(3); + expect(rows[0].innerHTML).toContain('RPM: 1000.00'); + expect(rows[1].innerHTML).toContain('MAP: 1.50'); + const colorDot = rows[0].querySelector('span'); + expect(colorDot.style.background).toBe('rgb(255, 0, 0)'); + }); + + test('Custom Tooltip: Hides when opacity is 0', () => { + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + const handler = Chart.mock.calls[0][1].options.plugins.tooltip.external; + const mockEl = document.createElement('div'); + const mockChart = { + canvas: { parentNode: { querySelector: () => mockEl } }, + }; + + handler({ chart: mockChart, tooltip: { opacity: 0 } }); + expect(mockEl.style.opacity).toBe('0'); + }); + + test('getOrCreateTooltip creates element if missing', () => { + const mockParent = document.createElement('div'); + const mockCanvas = document.createElement('canvas'); + mockParent.appendChild(mockCanvas); + + const chart = { canvas: mockCanvas }; + const tooltip = XYAnalysis.getOrCreateTooltip(chart); + + expect(tooltip.className).toBe('chartjs-tooltip'); + expect(mockParent.querySelector('.chartjs-tooltip')).not.toBeNull(); + }); + + test('resetAllZooms resets all charts', () => { + XYAnalysis.charts = [mockChartInstance, null]; + XYAnalysis.timelineChart = mockChartInstance; + XYAnalysis.resetAllZooms(); + expect(mockChartInstance.resetZoom).toHaveBeenCalledTimes(2); + }); + }); + + describe('Timeline Integration', () => { + test('renderTimeline creates chart with normalized data', () => { + AppState.files = [ + { + startTime: 1000, + availableSignals: ['RPM', 'Boost'], + signals: { + RPM: [ + { x: 1000, y: 0 }, + { x: 2000, y: 6000 }, + ], + Boost: [ + { x: 1000, y: 0 }, + { x: 2000, y: 1.5 }, + ], + }, + }, + ]; + XYAnalysis.renderTimeline(0, ['RPM', 'Boost']); + + const config = Chart.mock.calls[0][1]; + expect(config.data.datasets.length).toBe(2); + const rpmData = config.data.datasets.find((d) => d.label === 'RPM').data; + expect(rpmData[0].y).toBeCloseTo(0); + expect(rpmData[1].y).toBeCloseTo(1); + }); + + test('Timeline tooltip returns original values', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1'], + signals: { S1: [{ x: 0, y: 50 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['S1']); + + const callback = + Chart.mock.calls[0][1].options.plugins.tooltip.callbacks.label; + const text = callback({ + dataset: { label: 'S1' }, + raw: { originalValue: 50.1234 }, + }); + expect(text).toBe('S1: 50.12'); + }); + + test('renderTimeline uses PaletteManager for colors', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['SigA'], + signals: { SigA: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['SigA']); + const config = Chart.mock.calls[0][1]; + expect(config.data.datasets[0].borderColor).toBe('#ff0000'); + }); + + test('renderTimeline handles flatline signals (avoid divide-by-zero)', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['Flat'], + signals: { + Flat: [ + { x: 0, y: 100 }, + { x: 1, y: 100 }, + ], + }, + }, + ]; + XYAnalysis.renderTimeline(0, ['Flat']); + const data = Chart.mock.calls[0][1].data.datasets[0].data; + expect(data[0].y).toBe(0); + }); + + test('renderTimeline skips signals not found in file', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['A'], + signals: { A: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['A', 'Missing']); + expect(Chart.mock.calls[0][1].data.datasets).toHaveLength(1); + }); + + test('renderTimeline returns early if canvas missing', () => { + document.getElementById('xyTimelineCanvas').remove(); + XYAnalysis.renderTimeline(0, ['RPM']); + expect(Chart).not.toHaveBeenCalled(); + }); + + test('Timeline Hover Plugin draws line', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1'], + signals: { S1: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['S1']); + + const config = Chart.mock.calls[0][1]; + const hoverPlugin = config.plugins.find((p) => p.id === 'xyHoverLine'); + + const mockCtx = { + save: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + restore: jest.fn(), + lineWidth: 0, + strokeStyle: '', + }; + const mockChart = { + ctx: mockCtx, + tooltip: { _active: [{ element: { x: 50 } }] }, + scales: { y: { top: 10, bottom: 100 } }, + }; + + hoverPlugin.afterDraw(mockChart); + expect(mockCtx.moveTo).toHaveBeenCalledWith(50, 10); + expect(mockCtx.lineTo).toHaveBeenCalledWith(50, 100); + expect(mockCtx.stroke).toHaveBeenCalled(); + }); + + test('updateTimeline aggregates signals from all selectors', () => { + const setVal = (id, val) => { + document.getElementById(id).innerHTML = ``; + }; + setVal('xyX-0', 'S1'); + setVal('xyY-0', 'S2'); + setVal('xyZ-0', 'S1'); + setVal('xyX-1', 'S3'); + setVal('xyY-1', ''); + setVal('xyZ-1', ''); + + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1', 'S2', 'S3'], + signals: { S1: [], S2: [], S3: [] }, + }, + ]; + XYAnalysis.currentFileIndex = 0; + + const spy = jest.spyOn(XYAnalysis, 'renderTimeline'); + XYAnalysis.updateTimeline(); + + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['S1', 'S2', 'S3']) + ); + }); + + test('plot triggers both scatter and timeline updates', () => { + const scatterSpy = jest + .spyOn(XYAnalysis, 'renderChart') + .mockImplementation(() => {}); + const timelineSpy = jest + .spyOn(XYAnalysis, 'updateTimeline') + .mockImplementation(() => {}); + + document.getElementById('xyX-0').innerHTML = ``; + document.getElementById('xyY-0').innerHTML = ``; + document.getElementById('xyZ-0').innerHTML = ``; + + AppState.files = [{ name: 'F', startTime: 0, signals: {} }]; + XYAnalysis.currentFileIndex = 0; + + XYAnalysis.plot('0'); + + expect(scatterSpy).toHaveBeenCalled(); + expect(timelineSpy).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases & Full Coverage', () => { + test('getHeatColor handles values outside min/max range', () => { + // Value below min (0) should be clamped to min -> Hue 240 (Blue) + expect(XYAnalysis.getHeatColor(-50, 0, 100)).toContain('240'); + // Value above max (100) should be clamped to max -> Hue 0 (Red) + expect(XYAnalysis.getHeatColor(150, 0, 100)).toContain('0'); + }); + + test('renderChart destroys existing chart before creating new one', () => { + const destroySpy = jest.fn(); + XYAnalysis.charts = [{ destroy: destroySpy }, null]; + + AppState.files = [{ availableSignals: ['A', 'B', 'C'], signals: {} }]; + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + expect(destroySpy).toHaveBeenCalled(); + }); + + test('renderTimeline destroys existing chart', () => { + const destroySpy = jest.fn(); + XYAnalysis.timelineChart = { destroy: destroySpy }; + + AppState.files = [ + { + startTime: 0, + availableSignals: ['A'], + signals: { A: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['A']); + + expect(destroySpy).toHaveBeenCalled(); + }); + + test('renderTimeline handles missing data for valid signal', () => { + // Signal is in availableSignals but null in signals map + AppState.files = [ + { + startTime: 0, + availableSignals: ['GhostSignal'], + signals: { GhostSignal: null }, + }, + ]; + + XYAnalysis.renderTimeline(0, ['GhostSignal']); + + const config = Chart.mock.calls[0][1]; + expect(config.data.datasets.length).toBe(0); // Should be filtered out + }); + + test('Event listener cleanup when element is removed from DOM', () => { + XYAnalysis.createSearchableSelect('xyGlobalFile', ['A'], '', jest.fn()); + const container = document.getElementById('xyGlobalFile'); + const list = container.querySelector('.search-results-list'); + + // Remove container from DOM to trigger cleanup logic + container.remove(); + + // Click anywhere + document.body.click(); + + // Should verify that no errors occurred and list state didn't change (implied coverage) + expect(list).toBeDefined(); + }); + + test('plot returns early if inputs are missing', () => { + const renderSpy = jest.spyOn(XYAnalysis, 'renderChart'); + + // Only set X, leave Y and Z empty + document.getElementById('xyX-0').innerHTML = ``; + + XYAnalysis.plot('0'); + + expect(renderSpy).not.toHaveBeenCalled(); + }); + }); +});