Faster implementation with binary websocket

This commit is contained in:
Martino Ferrari
2026-05-21 15:48:08 +02:00
parent 62545503c4
commit 3315c02282
4 changed files with 658 additions and 37 deletions
+129 -25
View File
@@ -99,6 +99,7 @@ function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100;
let ws = null, wsBackoff = 1000;
function connectWS() {
ws = new WebSocket('ws://' + location.host + '/ws');
ws.binaryType = 'arraybuffer';
ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected waiting for data'); };
ws.onclose = () => {
setStatus('red', 'Disconnected (reconnecting…)');
@@ -107,6 +108,7 @@ function connectWS() {
};
ws.onerror = () => { };
ws.onmessage = evt => {
if (evt.data instanceof ArrayBuffer) { onBinaryData(evt.data); return; }
let msg; try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'sources') onSources(msg);
else if (msg.type === 'config') onConfig(msg);
@@ -200,6 +202,8 @@ function onData(msg) {
const len = Math.min(sd.t.length, sd.v.length);
for (let i = 0; i < len; i++) pushBuffer(buf, sd.t[i], sd.v[i]);
});
// Increment data generation counter so render loop knows data changed
_dataGen++;
if (trig.enabled && trig.armed && trig.signal) checkTrigger(sigs);
if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
@@ -212,8 +216,81 @@ function onData(msg) {
}
/* ════════════════════════════════════════════════════════════════
Trigger logic
Binary data handler — parses compact binary frames from Go backend.
Wire format (little-endian):
uint8 version (1)
uint8 sourceIdLen
UTF-8 sourceId
uint32 numSignals
for each signal:
uint16 keyLen
UTF-8 key (relative to source)
uint32 pairCount N
float64[N] t values
float64[N] v values
════════════════════════════════════════════════════════════════ */
function onBinaryData(buf) {
lastDataAt = performance.now();
const dv = new DataView(buf);
let off = 0;
if (dv.getUint8(off) !== 1) return;
off += 1;
const srcIdLen = dv.getUint8(off); off += 1;
const srcId = new TextDecoder().decode(new Uint8Array(buf, off, srcIdLen));
off += srcIdLen;
const prefix = srcId + ':';
const numSigs = dv.getUint32(off, true); off += 4;
// Collect trigger-signal values for inline check
let trigVals = null;
for (let s = 0; s < numSigs; s++) {
const keyLen = dv.getUint16(off, true); off += 2;
const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen));
off += keyLen;
const fullKey = prefix + key;
const n = dv.getUint32(off, true); off += 4;
let bufObj = buffers[fullKey];
if (!bufObj) {
bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP);
buffers[fullKey] = bufObj;
}
// Read t and v values in one pass (v array starts at off + n*8)
const tOff = off, vOff = off + n * 8;
for (let i = 0; i < n; i++) {
pushBuffer(bufObj, dv.getFloat64(tOff + i * 8, true), dv.getFloat64(vOff + i * 8, true));
}
off += n * 16; // skip both t and v arrays
// Capture trigger signal values
if (trig.enabled && trig.armed && fullKey === trig.signal) {
trigVals = { t: new Float64Array(n), v: new Float64Array(n) };
for (let i = 0; i < n; i++) {
trigVals.t[i] = dv.getFloat64(tOff + i * 8, true);
trigVals.v[i] = dv.getFloat64(vOff + i * 8, true);
}
}
}
// Trigger check
if (trigVals) checkTrigger(trigVals);
if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
if (!trig.enabled) {
_dataGen++;
plots.forEach(p => {
if (globalPause) return;
if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true;
});
}
}
function checkTrigger(sigs) {
const sd = sigs[trig.signal]; if (!sd || !sd.v || !sd.v.length) return;
for (let i = 0; i < sd.v.length; i++) {
@@ -747,8 +824,11 @@ function drawCursorLines(u, p) {
}
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
// Returns the min-of-max timestamp across all sources contributing traces to p,
// so no source shows a blank right edge. Falls back to Date.now()/1000 if no data.
// Returns the min-of-max timestamp across ACTIVE sources contributing traces to p,
// so no live source shows a blank right edge.
// Sources whose newest timestamp lags the fastest source by more than windowSec are
// considered stale (disconnected / from a previous session) and are excluded, so they
// cannot anchor the rolling window far in the past.
function computePlotNow(p) {
const sourceNewest = {};
p.traces.forEach(key => {
@@ -761,7 +841,11 @@ function computePlotNow(p) {
if (sourceNewest[srcId] === undefined || t > sourceNewest[srcId]) sourceNewest[srcId] = t;
});
const srcVals = Object.values(sourceNewest);
let now = srcVals.length > 0 ? Math.min(...srcVals) : -Infinity;
if (srcVals.length === 0) return Date.now() / 1000;
const globalMax = Math.max(...srcVals);
// Keep only sources that have received data within the last windowSec.
const active = srcVals.filter(t => t >= globalMax - windowSec);
let now = active.length > 0 ? Math.min(...active) : globalMax;
if (!isFinite(now)) now = Date.now() / 1000;
return now;
}
@@ -1031,27 +1115,21 @@ function resampleLinear(tSrc, vSrc, tDst) {
function buildLiveData(p) {
if (p.traces.length === 0) return [new Float64Array(0)];
// plotNow = min(newest per source) so no source shows a blank right edge.
const plotNow = computePlotNow(p);
const t0 = p.xRange ? p.xRange[0] : plotNow - windowSec;
const t1 = p.xRange ? p.xRange[1] : plotNow;
// Pixel-adaptive LTTB target: 2× plot width so zooming in automatically
// raises the effective sample cap and reveals full resolution.
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const isRolling = !p.xRange;
// When zoomed, prefer server-fetched hi-res data if it covers this exact range.
if (p.xRange) {
const zd = zoomData[p.id];
if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) {
return buildDataFromFetched(p, zd.signals, targetPts);
return buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
}
}
// Slice all traces once; pick the master time grid using configured samplingRate
// as the primary criterion (unambiguous, independent of buffer fill / trace order).
// Fall back to raw sample count for signals without a configured rate.
// Slice all traces; pick master by sampling rate then count.
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
@@ -1069,13 +1147,24 @@ function buildLiveData(p) {
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
// Decimate master with pixel-adaptive LTTB, use resulting grid for all others
const dec = lttb(masterRaw.t, masterRaw.v, targetPts);
const sharedT = dec.t;
// In rolling mode, Go backend already LTTB-decimated temporal signals to
// maxPushPoints (2000) and scalar points per tick are naturally limited.
// Skip JS-side LTTB entirely — just use the raw buffer data as-is.
// In zoomed mode, run pixel-adaptive LTTB for display quality.
let sharedT, masterV;
if (isRolling) {
sharedT = masterRaw.t;
masterV = masterRaw.v;
} else {
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const dec = lttb(masterRaw.t, masterRaw.v, targetPts);
sharedT = dec.t;
masterV = dec.v;
}
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; }
if (key === masterKey) { yArrays.push(masterV); continue; }
const sl = slices[key];
if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sl.t, sl.v, sharedT));
@@ -1759,7 +1848,7 @@ function addPlot() {
document.getElementById('plot-grid').appendChild(card);
const plotBody = card.querySelector('#pbody-' + id);
const p = { id, traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null };
const p = { id, traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 };
plots.push(p);
// uPlot creation is handled by applyLayout (batch, after DOM settles).
return id;
@@ -1822,8 +1911,8 @@ function deletePlot(plotId) {
Render loop
════════════════════════════════════════════════════════════════ */
let _dbgTick = 0;
let _dataGen = 0; // incremented each time new data arrives
function renderDirtyPlots() {
const inTrigMode = trig.enabled && trig.snapshot !== null;
// Diagnostic: every ~5 s print buffer state to the browser console.
// Open DevTools → Console to see timestamps and sizes.
@@ -1872,8 +1961,8 @@ function renderDirtyPlots() {
}
// Rolling-window plots: mark dirty every frame for smooth continuous scrolling.
// setScale is called AFTER setData inside the rebuild loop so the viewport and
// data slice are always computed with the same plotNow anchor.
// When no new data arrived since the last render, only advance the viewport
// via setScale instead of rebuilding all data arrays (much cheaper).
if (!trig.enabled && !globalPause) {
plots.forEach(p => {
if (!p.uplot || p.xRange) return;
@@ -1883,9 +1972,24 @@ function renderDirtyPlots() {
plots.forEach(p => {
if (!p.needsRedraw || !p.uplot) return;
p.needsRedraw = false;
const data = buildUPlotData(p, inTrigMode);
const inTrigModeNow = trig.enabled && trig.snapshot !== null;
const isRolling = !trig.enabled && !p.xRange;
// Fast path: rolling-window plot with no new data — just shift viewport.
if (isRolling && _dataGen === p.lastDataGen && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length > 0) {
p.needsRedraw = false;
zoomGuard = true;
const plotNow = computePlotNow(p);
p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
zoomGuard = false;
return;
}
p.needsRedraw = false;
p.lastDataGen = _dataGen;
const data = buildUPlotData(p, inTrigModeNow);
// setData internally triggers the setScale hook in uPlot (it reaffirms the
// current scale even with auto:false). Keep zoomGuard raised across the
@@ -1896,9 +2000,9 @@ function renderDirtyPlots() {
p.uplot.setData(data);
// Re-apply the x-scale after setData so the viewport stays correct.
if (trig.enabled && !inTrigMode) {
if (trig.enabled && !inTrigModeNow) {
// Armed / waiting for trigger: keep the current scale frozen.
} else if (inTrigMode) {
} else if (inTrigModeNow) {
const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
p.uplot.setScale('x', {