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;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user