webui: apply per-signal calibration to the whole display path

This commit is contained in:
Martino Ferrari
2026-08-17 00:43:18 +02:00
parent 7312dd0ca0
commit 0e9ec61226
+118 -13
View File
@@ -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);