diff --git a/Client/udpstreamer/static/app.js b/Client/udpstreamer/static/app.js index 0de3ea1..6cc3028 100644 --- a/Client/udpstreamer/static/app.js +++ b/Client/udpstreamer/static/app.js @@ -1352,7 +1352,11 @@ function decimateAsync(cacheKey, t, v, threshold, gen) { return result; } } - return cached || null; // stale entry, or nothing to draw yet + // Never hand out a stale decimation: drawing it (at its old timestamps) and + // then the fresh one a frame later is what makes the trace jump/shimmer on + // every push. Return null instead — the caller holds the previous render + // until the worker's fresh result lands (it flags the plot for redraw). + return null; } // Evict stale decimation cache entries for a plot (call when zoom range changes). @@ -2467,8 +2471,12 @@ function buildLiveData(p) { let dec; if (cached) { dec = cached; + } else if (p.uplot && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length) { + // Fresh decimation not ready yet — hold the previous render so the trace + // does not flicker between a stale decimation and the fresh one. + return p.uplot.data; } else { - // Worker job submitted — sync fallback this frame so the plot isn't blank. + // First render: worker job submitted, nothing on screen yet — sync. dec = decimate(masterRaw.t, masterRaw.v, targetPts); } sharedT = dec.t; @@ -2541,7 +2549,14 @@ function buildTrigData(p) { // same-length snapshot slice for the same range, so it is tagged separately. const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}:${usedFetched ? 'hi' : 'snap'}`; const cachedDec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts); - const dec = cachedDec || decimate(masterRaw.t, masterRaw.v, targetPts); + let dec; + if (cachedDec) { + dec = cachedDec; + } else if (p.uplot && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length) { + return p.uplot.data; // hold the previous render until the fresh decimation lands + } else { + dec = decimate(masterRaw.t, masterRaw.v, targetPts); + } // Convert absolute → relative seconds const sharedT = new Float64Array(dec.t.length); for (let i = 0; i < dec.t.length; i++) sharedT[i] = dec.t[i] - trigT; @@ -2598,8 +2613,15 @@ function buildTrigFillData(p) { masterV = masterRaw.v; } else { const cacheKey = `${p.id}:${masterKey}:trigfill`; - const dec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, _dataGen) || - decimate(masterRaw.t, masterRaw.v, targetPts); + const decd = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, _dataGen); + let dec; + if (decd) { + dec = decd; + } else if (p.uplot && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length) { + return p.uplot.data; // hold until the fresh decimation is ready + } else { + dec = decimate(masterRaw.t, masterRaw.v, targetPts); + } sharedAbsT = dec.t; masterV = dec.v; } @@ -3884,6 +3906,10 @@ function deletePlot(plotId) { let _dbgTick = 0; let _dataGen = 0; // incremented each time new data arrives function renderDirtyPlots() { + // Schedule the next frame FIRST: an exception below must never kill the + // animation loop, or every plot would freeze until a page refresh. + requestAnimationFrame(renderDirtyPlots); + try { // Compute global "now" once — shared by all rolling-window plots this frame. const globalPlotNow = getGlobalNow(); @@ -3953,7 +3979,7 @@ function renderDirtyPlots() { plots.forEach(p => { if (!p.needsRedraw || !p.uplot || p.traces.length === 0) return; - + try { const inTrigModeNow = inTrigWindow(); // The x tick formatter and the cursor-sync group are baked into the uPlot // options at construction. A plot built in live mode therefore keeps @@ -3968,7 +3994,10 @@ function renderDirtyPlots() { if (isRolling && _dataGen === p.lastDataGen && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length > 0) { p.needsRedraw = false; zoomGuard = true; - p.uplot.setScale('x', { min: globalPlotNow - windowSec, max: globalPlotNow }); + // Use the same per-plot anchor as the rebuild path, so the rolling window + // does not jump when the frame switches between the two. + const plotNow = computePlotNow(p); + p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow }); zoomGuard = false; return; } @@ -4005,12 +4034,25 @@ function renderDirtyPlots() { p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow }); } zoomGuard = false; + p._errCount = 0; + } catch (e) { + // One bad plot must not kill the whole render loop. Track consecutive + // failures and self-heal by rebuilding the uPlot instance. + p._errCount = (p._errCount || 0) + 1; + console.error(`[render] plot ${p.id}:`, e); + p.needsRedraw = true; // retry next frame + if (p._errCount >= 30) { + p._errCount = 0; + try { createUPlot(p); } catch (e2) { console.error(`[render] rebuild plot ${p.id}:`, e2); } + } + } }); // Keep per-plot cursor value readouts in sync with live data. if (cursors.mode === 'on') updatePlotCursorReadouts(); - - requestAnimationFrame(renderDirtyPlots); + } catch (e) { + console.error('[render]', e); + } }