Implemented backend hr resolution data, splitted test gam
This commit is contained in:
+228
-11
@@ -64,6 +64,11 @@ let zoomGuard = false;
|
||||
// Zoom history for Back button (global since plots are zoom-synced)
|
||||
const zoomHistory = [];
|
||||
|
||||
// zoomData: hi-res data fetched from /api/zoom, keyed by plot id.
|
||||
// Each entry: { signals: { key: {t:Float64Array, v:Float64Array} }, t0, t1 }
|
||||
const zoomData = {};
|
||||
let _zoomFetchTimer = null;
|
||||
|
||||
// Cursors A/B — stored in x-axis units of the current mode:
|
||||
// live mode → Unix seconds
|
||||
// trig mode → relative seconds from trigger
|
||||
@@ -248,6 +253,46 @@ function finaliseTriggerCapture() {
|
||||
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
|
||||
if (trig.mode === 'normal' && !trig.stopped)
|
||||
setTimeout(() => { if (trig.enabled && trig.mode === 'normal' && !trig.stopped) trigArm(); }, 200);
|
||||
|
||||
// Upgrade the snapshot in the background with full-resolution ring data.
|
||||
// The push buffer has min Δt ≈ 16 µs; the ring provides ≈ 1.65 µs.
|
||||
upgradeTriggerSnapshot(t0, t1, trig.trigTime);
|
||||
}
|
||||
|
||||
// Fetches full-resolution ring data for the trigger window and replaces the
|
||||
// push-buffer entries in trig.snapshot, then re-renders all plots.
|
||||
async function upgradeTriggerSnapshot(t0, t1, capturedTrigTime) {
|
||||
// Small delay so the ring receives the last post-trigger samples
|
||||
// (ring write period ≈ 33 ms; 120 ms gives ≥ 3 ticks of margin).
|
||||
await new Promise(r => setTimeout(r, 120));
|
||||
// Abort if a new trigger has fired since we started.
|
||||
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
|
||||
|
||||
const keys = Object.keys(buffers);
|
||||
if (!keys.length) return;
|
||||
|
||||
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
|
||||
let ringSignals;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
ringSignals = json && json.signals;
|
||||
} catch (e) {
|
||||
console.warn('trigger snapshot ring upgrade failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort if the snapshot was replaced while we were fetching.
|
||||
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
|
||||
|
||||
let updated = false;
|
||||
Object.entries(ringSignals || {}).forEach(([key, sd]) => {
|
||||
if (!sd || !sd.t || !sd.t.length) return;
|
||||
trig.snapshot[key] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
|
||||
updated = true;
|
||||
});
|
||||
if (updated) plots.forEach(p => { p.needsRedraw = true; });
|
||||
}
|
||||
function trigArm() {
|
||||
// Do NOT clear trig.snapshot here — keep the last waveform and trigger marker
|
||||
@@ -431,6 +476,81 @@ function lttb(t, v, threshold) {
|
||||
return { t: outT, v: outV };
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Hybrid zoom: hi-res data fetched from /api/zoom on demand
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
function cancelZoomFetch() {
|
||||
if (_zoomFetchTimer !== null) { clearTimeout(_zoomFetchTimer); _zoomFetchTimer = null; }
|
||||
}
|
||||
function scheduleZoomFetch(t0, t1) {
|
||||
cancelZoomFetch();
|
||||
_zoomFetchTimer = setTimeout(() => { _zoomFetchTimer = null; doZoomFetch(t0, t1); }, 150);
|
||||
}
|
||||
|
||||
async function doZoomFetch(t0, t1) {
|
||||
// Collect all signal keys across all plots; each key encodes sourceId as prefix.
|
||||
const allKeys = new Set();
|
||||
plots.forEach(p => p.traces.forEach(k => allKeys.add(k)));
|
||||
if (allKeys.size === 0) return;
|
||||
|
||||
const targetPts = Math.max(400, (plots.find(p => p.uplot)?.uplot?.width || 600) * 2);
|
||||
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=${targetPts}&signals=${[...allKeys].join(',')}`;
|
||||
let fetched;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return;
|
||||
fetched = await resp.json();
|
||||
} catch (e) { console.warn('zoom fetch:', e); return; }
|
||||
|
||||
const sigs = fetched && fetched.signals;
|
||||
if (!sigs) return;
|
||||
|
||||
// Convert arrays from JSON to Float64Array and store per-plot.
|
||||
const merged = {};
|
||||
Object.entries(sigs).forEach(([k, sd]) => {
|
||||
if (!sd || !sd.t || !sd.v) return;
|
||||
merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
|
||||
});
|
||||
|
||||
plots.forEach(p => {
|
||||
// Only store if this plot's range still matches what we fetched.
|
||||
if (!p.xRange || Math.abs(p.xRange[0] - t0) > 1e-9 || Math.abs(p.xRange[1] - t1) > 1e-9) return;
|
||||
const plotSigs = {};
|
||||
p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; });
|
||||
if (Object.keys(plotSigs).length > 0) {
|
||||
zoomData[p.id] = { signals: plotSigs, t0, t1 };
|
||||
p.needsRedraw = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build uPlot data arrays from server-fetched hi-res signal data.
|
||||
function buildDataFromFetched(p, fetchedSignals, targetPts) {
|
||||
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
|
||||
for (const key of p.traces) {
|
||||
const sd = fetchedSignals[key];
|
||||
if (!sd || !sd.t || !sd.t.length) continue;
|
||||
const rate = getKeySamplingRate(key);
|
||||
if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) {
|
||||
masterRate = rate; masterCount = sd.t.length; masterKey = key;
|
||||
}
|
||||
}
|
||||
const masterSd = fetchedSignals[masterKey];
|
||||
if (!masterSd || !masterSd.t.length)
|
||||
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
|
||||
|
||||
const dec = lttb(masterSd.t, masterSd.v, targetPts);
|
||||
const sharedT = dec.t;
|
||||
const yArrays = [];
|
||||
for (const key of p.traces) {
|
||||
if (key === masterKey) { yArrays.push(dec.v); continue; }
|
||||
const sd = fetchedSignals[key];
|
||||
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
|
||||
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
|
||||
}
|
||||
return [sharedT, ...yArrays];
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
uPlot helpers
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
@@ -903,6 +1023,14 @@ function buildLiveData(p) {
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1019,6 +1147,9 @@ function onZoom(sourcePlotId, min, max) {
|
||||
|
||||
// Mark all plots dirty (re-slice data to the new range for full resolution)
|
||||
plots.forEach(p => { p.needsRedraw = true; });
|
||||
|
||||
// Schedule hi-res fetch from ring buffers.
|
||||
scheduleZoomFetch(min, max);
|
||||
}
|
||||
|
||||
// Undo last zoom/pan action
|
||||
@@ -1027,6 +1158,10 @@ function zoomBack() {
|
||||
const prev = zoomHistory.pop();
|
||||
if (!zoomHistory.length) document.getElementById('btn-zoom-back').style.display = 'none';
|
||||
|
||||
// Discard stale zoom data regardless of direction.
|
||||
Object.keys(zoomData).forEach(k => delete zoomData[k]);
|
||||
cancelZoomFetch();
|
||||
|
||||
if (prev === null) {
|
||||
// Was at auto/rolling state before the zoom
|
||||
resetZoom();
|
||||
@@ -1038,11 +1173,14 @@ function zoomBack() {
|
||||
p.needsRedraw = true;
|
||||
});
|
||||
zoomGuard = false;
|
||||
scheduleZoomFetch(prev[0], prev[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to auto/rolling window (clears all zoom)
|
||||
function resetZoom() {
|
||||
Object.keys(zoomData).forEach(k => delete zoomData[k]);
|
||||
cancelZoomFetch();
|
||||
syncLocked = false;
|
||||
document.getElementById('btn-sync-resume').style.display = 'none';
|
||||
if (trig.enabled && trig.snapshot) {
|
||||
@@ -1097,6 +1235,7 @@ function zoomFit() {
|
||||
p.needsRedraw = true;
|
||||
});
|
||||
zoomGuard = false;
|
||||
scheduleZoomFetch(gMin, gMax);
|
||||
}
|
||||
|
||||
// Auto = return to rolling window / full trigger window
|
||||
@@ -1464,34 +1603,112 @@ function buildLayoutMenu() {
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Export CSV (all plots)
|
||||
Export CSV (all plots) — fetches full-resolution data from ring
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
function exportAllCSV() {
|
||||
async function exportAllCSV() {
|
||||
const btn = document.getElementById('btn-csv-all');
|
||||
if (btn.disabled) return;
|
||||
|
||||
const inTrigMode = trig.enabled && trig.snapshot !== null;
|
||||
// Collect unique signal keys across all plots (preserving order)
|
||||
|
||||
// Collect unique signal keys across all plots (preserving order).
|
||||
const keys = [];
|
||||
plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); }));
|
||||
if (!keys.length) return;
|
||||
|
||||
// Determine time range to export.
|
||||
let t0, t1, relOffset = 0;
|
||||
if (inTrigMode) {
|
||||
// Export the full trigger window around the trigger event.
|
||||
t0 = trig.trigTime - trigPreSec();
|
||||
t1 = trig.trigTime + trigPostSec();
|
||||
relOffset = trig.trigTime;
|
||||
} else {
|
||||
// Use the current zoom range if active, else the rolling window.
|
||||
const refPlot = plots.find(p => p.xRange);
|
||||
if (refPlot) {
|
||||
[t0, t1] = refPlot.xRange;
|
||||
} else {
|
||||
let plotNow = -Infinity;
|
||||
keys.forEach(k => {
|
||||
const buf = buffers[k];
|
||||
if (buf && buf.size > 0) {
|
||||
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
|
||||
if (t > plotNow) plotNow = t;
|
||||
}
|
||||
});
|
||||
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
|
||||
t0 = plotNow - windowSec;
|
||||
t1 = plotNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state.
|
||||
const origLabel = btn.textContent;
|
||||
btn.textContent = '⏳ Downloading…';
|
||||
btn.disabled = true;
|
||||
|
||||
// Fetch full-resolution ring data (n=0 → no LTTB decimation).
|
||||
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
|
||||
let ringSignals = null;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
ringSignals = json && json.signals;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('CSV export: ring fetch failed, falling back to push buffer', e);
|
||||
} finally {
|
||||
btn.textContent = origLabel;
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// Build per-signal time/value arrays.
|
||||
// Priority: ring buffer (full res) → trigger snapshot → push buffer.
|
||||
const slices = keys.map(key => {
|
||||
const rd = ringSignals && ringSignals[key];
|
||||
if (rd && rd.t && rd.t.length > 0) {
|
||||
const t = rd.t, v = rd.v;
|
||||
if (inTrigMode) {
|
||||
return { t: Array.from(t).map(ts => ts - relOffset), v: Array.from(v) };
|
||||
}
|
||||
return { t: Array.from(t), v: Array.from(v) };
|
||||
}
|
||||
// Fallback: push buffer or trigger snapshot.
|
||||
if (inTrigMode) {
|
||||
const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) };
|
||||
return { t: Array.from(raw.t).map(ts => ts - trig.trigTime), v: Array.from(raw.v) };
|
||||
return { t: Array.from(raw.t).map(ts => ts - relOffset), v: Array.from(raw.v) };
|
||||
}
|
||||
const buf = buffers[key]; if (!buf) return { t: [], v: [] };
|
||||
const sl = getBufferSlice(buf);
|
||||
const sl = getBufferSliceRange(buf, t0, t1);
|
||||
return { t: Array.from(sl.t), v: Array.from(sl.v) };
|
||||
});
|
||||
|
||||
const allT = new Set(); slices.forEach(s => s.t.forEach(t => allT.add(t)));
|
||||
// Merge all timestamps and build aligned rows.
|
||||
const allT = new Set();
|
||||
slices.forEach(s => s.t.forEach(t => allT.add(t)));
|
||||
const sortedT = Array.from(allT).sort((a, b) => a - b);
|
||||
const lookups = slices.map(s => { const m = new Map(); s.t.forEach((t, i) => m.set(t, s.v[i])); return m; });
|
||||
const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...keys].join(',');
|
||||
const rows = sortedT.map(t => [t.toFixed(9), ...lookups.map(lk => lk.has(t) ? lk.get(t) : '')].join(','));
|
||||
if (!sortedT.length) return;
|
||||
|
||||
const lookups = slices.map(s => {
|
||||
const m = new Map();
|
||||
s.t.forEach((t, i) => m.set(t, s.v[i]));
|
||||
return m;
|
||||
});
|
||||
|
||||
// Strip "sourceId:" prefix from column headers for readability.
|
||||
const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k));
|
||||
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(',')
|
||||
);
|
||||
const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = 'signals_' + Date.now() + '.csv';
|
||||
a.click(); URL.revokeObjectURL(a.href);
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'signals_' + Date.now() + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user