webui: calibrate CSV export, trigger threshold and unit display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-17 00:55:07 +02:00
co-authored by Claude Sonnet 4.6
parent 4908c039a5
commit 3cb998c5da
3 changed files with 43 additions and 10 deletions
+36 -9
View File
@@ -706,14 +706,25 @@ function onBinaryData(buf) {
function wsSend(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
// trig.threshold is held in calibrated units. The hub's comparator runs on raw
// samples, so invert on the way out: raw = (calibrated - offset) / scale.
function sendTrigConfig() {
const cal = trig.signal ? calForKey(trig.signal) : Calib.IDENTITY;
wsSend({
type: 'setTrigger', signal: trig.signal, edge: trig.edge,
threshold: trig.threshold, windowSec: trig.windowSec,
threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec,
prePercent: trig.prePercent, mode: trig.mode,
});
}
// Rewrite the threshold input and its unit hint from trig.threshold.
function refreshTrigThresholdField() {
const el = document.getElementById('trig-threshold');
if (document.activeElement !== el) el.value = trig.threshold;
const u = trig.signal ? unitForKey(trig.signal) : '';
el.title = u ? 'Threshold in ' + u : 'Threshold in the signal\u2019s raw units';
}
// Hub FSM broadcast: {state:"idle|armed|collecting|triggered", mode, stopped[, trigTime]}
function onTriggerState(msg) {
const st = msg.state || 'idle';
@@ -2423,7 +2434,11 @@ function showHoverReadout(p, e) {
p.traces.forEach((key, idx) => {
const vNorm = interpAtTime(p.uplot, idx + 1, t);
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
const val = vNorm === null ? '—' : _fmtVal(rawFromNorm(p, key, vNorm));
// rawFromNorm inverts the vscale transform, which Task 7 made operate on
// calibrated values — so this is already in calibrated units.
const unit = unitForKey(key);
const val = vNorm === null ? '—'
: (_fmtVal(rawFromNorm(p, key, vNorm)) + (unit ? ' ' + unit : ''));
html += '<div class="hov-row"><span class="hov-dot" style="background:' +
escHtml(getSigStyle(key).color) + '"></span>' +
'<span class="hov-name">' + escHtml(name) + '</span>' +
@@ -2612,6 +2627,7 @@ function buildTrigSignalSelect() {
// Restore selection: match base key so array element "sig[3]" selects "sig" option.
if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase;
// Do NOT overwrite trig.signal here — an array element selection must be preserved.
refreshTrigThresholdField();
}
/* ════════════════════════════════════════════════════════════════
@@ -2660,19 +2676,20 @@ function buildSidebar() {
const n = numElements(sig), temporal = isTemporal(sig);
const typeName = _typeNames[sig.typeCode] || '?';
const globalKey = prefix + sig.name;
const effUnit = unitForKey(globalKey);
if (n === 1 || temporal) {
grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, effUnit));
} else {
const group = document.createElement('div'); group.className = 'array-group';
const header = document.createElement('div'); header.className = 'array-header';
header.innerHTML = '<span class="array-arrow">&#x25B6;</span><span class="sig-name">' + escHtml(sig.name) + '</span>'
+ (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '')
+ (effUnit ? '<span class="sig-unit">' + escHtml(effUnit) + '</span>' : '')
+ '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
header.addEventListener('click', () => header.classList.toggle('open'));
const children = document.createElement('div'); children.className = 'array-children';
for (let i = 0; i < n; i++) {
const key = globalKey + '[' + i + ']';
const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');
const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, effUnit);
child.className = 'array-child'; children.appendChild(child);
}
group.appendChild(header); group.appendChild(children); grp.appendChild(group);
@@ -3028,11 +3045,19 @@ async function exportAllCSV() {
return m;
});
// Strip "sourceId:" prefix from column headers for readability.
const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k));
// Strip "sourceId:" prefix from column headers for readability, and append
// the effective unit. These values come straight from the ring/history/
// snapshot and never pass through applyVScaleNorm, so calibrate them here.
const cals = keys.map(k => calForKey(k));
const displayKeys = keys.map(k => {
const name = k.includes(':') ? k.split(':').slice(1).join(':') : k;
const u = unitForKey(k);
return u ? name + ' [' + u + ']' : name;
});
const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(',');
const rows = sortedT.map(t =>
[t.toFixed(9), ...lookups.map(lk => (lk.has(t) ? lk.get(t) : ''))].join(',')
[t.toFixed(9), ...lookups.map((lk, i) =>
lk.has(t) ? Calib.applyCal(lk.get(t), cals[i]) : '')].join(',')
);
const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' });
const a = document.createElement('a');
@@ -3441,7 +3466,9 @@ function applyCalibrationChanged() {
buildSidebar(); // unit badges
plots.forEach(p => { p.needsRedraw = true; });
if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu(); // Task 8
if (typeof sendTrigConfig === 'function') sendTrigConfig(); // Task 9
// The threshold is held in calibrated units, so a calibration change alters
// the raw value the hub must compare against — resend it.
if (trig.signal) { refreshTrigThresholdField(); sendTrigConfig(); }
}
// Replaced in Task 10 with the Sources & Config status renderer.
+3 -1
View File
@@ -40,7 +40,9 @@
var offset = obj.offset === undefined ? 0 : obj.offset;
if (!isFiniteNum(scale) || scale === 0) return null;
if (!isFiniteNum(offset)) return null;
var unit = String(obj.unit == null ? '' : obj.unit).trim();
// Commas and quotes would corrupt the CSV export header; drop them here so
// every consumer sees an already-safe unit.
var unit = String(obj.unit == null ? '' : obj.unit).replace(/[",]/g, '').trim();
// Cap at MAX_UNIT_LEN UTF-8 bytes, matching both hubs' Normalise() exactly.
// TextEncoder/TextDecoder are available natively in all modern browsers and
// Node v11+; no build step or bundler is needed.
@@ -150,3 +150,7 @@ test('CalTable.list is sorted by source then signal', () => {
assert.deepStrictEqual(t.list().map(e => e.source + '/' + e.signal),
['a/b', 'a/z', 'z/a']);
});
test('normaliseCal strips characters that would corrupt a CSV header', () => {
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'k,V"'}).unit, 'kV');
});