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