diff --git a/Client/udpstreamer/static/app.js b/Client/udpstreamer/static/app.js index 6473e46..1bf2058 100644 --- a/Client/udpstreamer/static/app.js +++ b/Client/udpstreamer/static/app.js @@ -103,7 +103,71 @@ function findSignalMeta(key) { if (colon < 0) return null; const src = sourcesMap[key.slice(0, colon)]; if (!src) return null; - return src.signals.find(s => s.name === key.slice(colon + 1)) || null; + const name = key.slice(colon + 1); + return src.signals.find(s => s.name === name) + || src.signals.find(s => s.name === Calib.baseSignalName(name)) + || null; +} + +/* ─── Calibration ────────────────────────────────────────────────────────── */ +// Per-signal affine calibration, keyed by (source LABEL, base signal name). +// The label rather than the runtime id ('s1', 's2') is used because ids are +// assigned in add-order at startup, so an id-keyed entry would rebind to a +// different source whenever the source list order changed. +const CAL_LS_KEY = 'udpscope.calibration'; +const calTable = new Calib.CalTable(); + +// Seeded from localStorage so calibration survives a reload against a hub that +// predates this feature (or one started without a config file). The first +// `calibration` frame from the hub overwrites it wholesale. +try { + const saved = localStorage.getItem(CAL_LS_KEY); + if (saved) calTable.replaceAll(JSON.parse(saved)); +} catch { /* corrupt or unavailable storage: start empty */ } + +function persistCalibration() { + try { localStorage.setItem(CAL_LS_KEY, JSON.stringify(calTable.list())); } + catch { /* quota or private mode: the hub copy is still authoritative */ } +} + +// Signal key "s1:Adc[3]" → the source's label ("wave"), or '' if unknown. +function srcLabelForKey(key) { + const colon = key.indexOf(':'); + if (colon < 0) return ''; + const src = sourcesMap[key.slice(0, colon)]; + return src ? (src.label || src.id) : ''; +} + +// Signal key "s1:Adc[3]" → base signal name ("Adc"). +function baseSigForKey(key) { + const colon = key.indexOf(':'); + return Calib.baseSignalName(colon < 0 ? key : key.slice(colon + 1)); +} + +// Never returns null — an uncalibrated signal yields Calib.IDENTITY. +function calForKey(key) { + return calTable.get(srcLabelForKey(key), baseSigForKey(key)); +} + +// The unit to show: the calibration override when set, else the streamer's. +function unitForKey(key) { + const cal = calForKey(key); + if (cal.unit) return cal.unit; + const meta = findSignalMeta(key); + return (meta && meta.unit) || ''; +} + +// Allocate a calibrated copy of a raw array. Returns the input untouched when +// the signal is uncalibrated, so the common case costs nothing. +function calibrateArray(key, rawY) { + const cal = calForKey(key); + if (cal.scale === 1 && cal.offset === 0) return rawY; + const out = new Float64Array(rawY.length); + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; + out[i] = (v == null || !isFinite(v)) ? NaN : v * cal.scale + cal.offset; + } + return out; } // Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array. @@ -116,8 +180,12 @@ function resolveVScale(plotId, key, rawY) { if (vs.mode === 'range') { const meta = findSignalMeta(key); if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) { - const divValue = niceDiv((meta.rangeMax - meta.rangeMin) / 8); - const offset = Math.round((meta.rangeMin + meta.rangeMax) / 2 / divValue) * divValue; + // rangeMin/rangeMax come from the streamer CONFIG in raw units and never + // pass through applyVScaleNorm, so calibrate them here. calRange re-orders + // the pair, which a negative scale would otherwise swap. + const [lo, hi] = Calib.calRange(meta.rangeMin, meta.rangeMax, calForKey(key)); + const divValue = niceDiv((hi - lo) / 8); + const offset = Math.round((lo + hi) / 2 / divValue) * divValue; vs._resolvedDiv = divValue; vs._resolvedOffset = offset; return { divValue, offset, screenPos }; } @@ -182,16 +250,19 @@ function applyMixedNorm(p, yArrays) { } // Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces). -// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos. +// Calibration is applied first, so divValue/offset — and therefore the cursor, +// hover, ruler and Y-axis readouts derived from them — are all in calibrated +// units. Returns y_norm = (y_cal - offset) / divValue + screenPos. function applyVScaleNorm(p, yArrays) { - if (p.mode === 'digital') return applyDigitalNorm(p, yArrays); - if (p.mode === 'mixed') return applyMixedNorm(p, yArrays); - return yArrays.map((rawY, ki) => { + const calArrays = yArrays.map((rawY, ki) => calibrateArray(p.traces[ki], rawY)); + if (p.mode === 'digital') return applyDigitalNorm(p, calArrays); + if (p.mode === 'mixed') return applyMixedNorm(p, calArrays); + return calArrays.map((y, ki) => { const key = p.traces[ki]; - const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY); - const out = new Float64Array(rawY.length); - for (let i = 0; i < rawY.length; i++) { - const v = rawY[i]; + const { divValue, offset, screenPos } = resolveVScale(p.id, key, y); + const out = new Float64Array(y.length); + for (let i = 0; i < y.length; i++) { + const v = y[i]; out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos; } return out; @@ -372,6 +443,8 @@ function connectWS() { else if (msg.type === 'historyZoom') onHistoryZoomReply(msg); else if (msg.type === 'historyInfo') onHistoryInfo(msg); else if (msg.type === 'monotonicState') onMonotonicState(msg); + else if (msg.type === 'calibration') onCalibration(msg); + else if (msg.type === 'configSaved' || msg.type === 'configReloaded') onConfigAck(msg); }; } @@ -3298,6 +3371,12 @@ document.getElementById('btn-sidebar').addEventListener('click', () => setSideba /* ════════════════════════════════════════════════════════════════ Multi-source management ════════════════════════════════════════════════════════════════ */ +// The hub's calibration table is authoritative: replace ours wholesale. +function onCalibration(msg) { + calTable.replaceAll(msg.cal || []); + applyCalibrationChanged(); +} + function onSources(msg) { const srcs = msg.sources || []; const newIds = new Set(srcs.map(s => s.id)); @@ -3336,12 +3415,38 @@ function removeSource(id) { } } -function saveSourcesWS() { +function saveConfigWS() { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'saveSources' })); } } +function reloadConfigWS() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'reloadConfig' })); + } +} + +function setCalibrationWS(source, signal, scale, offset, unit) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'setCalibration', source, signal, scale, offset, unit })); + } +} + +// Called after the calibration table changes, from any source (local edit, +// hub broadcast, or reload). Mirrors to localStorage and re-renders everything +// that shows a value or a unit. +function applyCalibrationChanged() { + persistCalibration(); + buildSidebar(); // unit badges + plots.forEach(p => { p.needsRedraw = true; }); + if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu(); // Task 8 + if (typeof sendTrigConfig === 'function') sendTrigConfig(); // Task 9 +} + +// Replaced in Task 10 with the Sources & Config status renderer. +function onConfigAck(msg) { /* no-op until Task 10 */ } + function makeAddSourceSection() { const section = document.createElement('div'); section.className = 'add-source-section'; @@ -3384,7 +3489,7 @@ function makeAddSourceSection() { const saveBtn = document.createElement('button'); saveBtn.className = 'add-src-btn save-src-btn'; saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file'; - saveBtn.addEventListener('click', saveSourcesWS); + saveBtn.addEventListener('click', saveConfigWS); body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn); section.append(title, body);