webui: apply per-signal calibration to the whole display path
This commit is contained in:
@@ -103,7 +103,71 @@ function findSignalMeta(key) {
|
|||||||
if (colon < 0) return null;
|
if (colon < 0) return null;
|
||||||
const src = sourcesMap[key.slice(0, colon)];
|
const src = sourcesMap[key.slice(0, colon)];
|
||||||
if (!src) return null;
|
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.
|
// 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') {
|
if (vs.mode === 'range') {
|
||||||
const meta = findSignalMeta(key);
|
const meta = findSignalMeta(key);
|
||||||
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
|
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
|
||||||
const divValue = niceDiv((meta.rangeMax - meta.rangeMin) / 8);
|
// rangeMin/rangeMax come from the streamer CONFIG in raw units and never
|
||||||
const offset = Math.round((meta.rangeMin + meta.rangeMax) / 2 / divValue) * divValue;
|
// 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;
|
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||||
return { divValue, offset, screenPos };
|
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).
|
// 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) {
|
function applyVScaleNorm(p, yArrays) {
|
||||||
if (p.mode === 'digital') return applyDigitalNorm(p, yArrays);
|
const calArrays = yArrays.map((rawY, ki) => calibrateArray(p.traces[ki], rawY));
|
||||||
if (p.mode === 'mixed') return applyMixedNorm(p, yArrays);
|
if (p.mode === 'digital') return applyDigitalNorm(p, calArrays);
|
||||||
return yArrays.map((rawY, ki) => {
|
if (p.mode === 'mixed') return applyMixedNorm(p, calArrays);
|
||||||
|
return calArrays.map((y, ki) => {
|
||||||
const key = p.traces[ki];
|
const key = p.traces[ki];
|
||||||
const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY);
|
const { divValue, offset, screenPos } = resolveVScale(p.id, key, y);
|
||||||
const out = new Float64Array(rawY.length);
|
const out = new Float64Array(y.length);
|
||||||
for (let i = 0; i < rawY.length; i++) {
|
for (let i = 0; i < y.length; i++) {
|
||||||
const v = rawY[i];
|
const v = y[i];
|
||||||
out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos;
|
out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -372,6 +443,8 @@ function connectWS() {
|
|||||||
else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
|
else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
|
||||||
else if (msg.type === 'historyInfo') onHistoryInfo(msg);
|
else if (msg.type === 'historyInfo') onHistoryInfo(msg);
|
||||||
else if (msg.type === 'monotonicState') onMonotonicState(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
|
Multi-source management
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
// The hub's calibration table is authoritative: replace ours wholesale.
|
||||||
|
function onCalibration(msg) {
|
||||||
|
calTable.replaceAll(msg.cal || []);
|
||||||
|
applyCalibrationChanged();
|
||||||
|
}
|
||||||
|
|
||||||
function onSources(msg) {
|
function onSources(msg) {
|
||||||
const srcs = msg.sources || [];
|
const srcs = msg.sources || [];
|
||||||
const newIds = new Set(srcs.map(s => s.id));
|
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) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'saveSources' }));
|
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() {
|
function makeAddSourceSection() {
|
||||||
const section = document.createElement('div');
|
const section = document.createElement('div');
|
||||||
section.className = 'add-source-section';
|
section.className = 'add-source-section';
|
||||||
@@ -3384,7 +3489,7 @@ function makeAddSourceSection() {
|
|||||||
const saveBtn = document.createElement('button');
|
const saveBtn = document.createElement('button');
|
||||||
saveBtn.className = 'add-src-btn save-src-btn';
|
saveBtn.className = 'add-src-btn save-src-btn';
|
||||||
saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
|
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);
|
body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn);
|
||||||
section.append(title, body);
|
section.append(title, body);
|
||||||
|
|||||||
Reference in New Issue
Block a user