@@ -476,14 +476,24 @@ let cursorsDirty = false; // if true, redraw all plots to update cursor lines
// Rolling-window anchor used to keep cursors visually fixed while live data scrolls.
let _cursorAnchorNow = null ;
// Horizontal value rulers — stored in normalized division units (the shared
// y scale, -4.5…4.5) so one pair applies to every plot regardless of V/div.
const rulers = { mode : 'off' , yA : null , yB : null } ;
// Horizontal value rulers. The on/off toggle is global, but each plot keeps its
// own pair of normalized-division positions (rulerState), so dragging Y1 in
// one plot does not move it in the others.
const rulers = { mode : 'off' , plotId : null } ;
const rulerState = { } ; // plotId → { yA, yB }
// Layout — [label, cssClass, cols, rows]
function getRulerState ( plotId ) {
if ( ! rulerState [ plotId ] ) rulerState [ plotId ] = { yA : null , yB : null } ;
return rulerState [ plotId ] ;
}
// Layout — [label, cssClass, cols, rows, (optional) plotCount].
// Custom (non-uniform) layouts carry an explicit plotCount; the grid template
// and the spanning cells are defined in style.css under #plot-grid.<class>.
const LAYOUTS = [
[ '1× 1' , 'l1x1' , 1 , 1 ] , [ '1× 2' , 'l1x2' , 1 , 2 ] , [ '2× 1' , 'l2x1' , 2 , 1 ] , [ '1× 3' , 'l1x3' , 1 , 3 ] ,
[ '3× 1' , 'l3x1' , 3 , 1 ] , [ '2× 2' , 'l2x2' , 2 , 2 ] , [ '1× 4' , 'l1x4' , 1 , 4 ] , [ '4× 1' , 'l4x1' , 4 , 1 ] ,
[ '1+2' , 'l1p2' , 2 , 2 , 3 ] , // one plot spanning the top row, two below
] ;
let currentLayout = 'l1x1' ;
let colFrs = [ 1 ] ; // fractional column sizes (sum = cols)
@@ -816,6 +826,7 @@ function onConfig(msg) {
}
buildSidebar ( ) ;
buildTrigSignalSelect ( ) ;
maybeRestoreViewLate ( ) ;
}
/* ════════════════════════════════════════════════════════════════
@@ -925,12 +936,21 @@ function wsSend(obj) {
function sendWindow ( ) {
wsSend ( { type : 'setWindow' , seconds : windowSec } ) ;
}
// trig.threshold is held in calibrated units. The hub' s comparator runs on raw
// 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 ;
// A negative calibration gain flips the signal on screen (v_cal = v_raw·scale
// + offset with scale < 0), so a calibrated rising edge is a raw FALLING
// edge. The hub compares raw samples, so send the raw direction that matches
// the edge the user picked on the calibrated trace.
let edge = trig . edge ;
if ( cal . scale < 0 ) {
if ( edge === 'rising' ) edge = 'falling' ;
else if ( edge === 'falling' ) edge = 'rising' ;
}
wsSend ( {
type : 'setTrigger' , signal : trig . signal , edge : trig . edge ,
type : 'setTrigger' , signal : trig . signal , edge : edge ,
threshold : Calib . invertCal ( trig . threshold , cal ) , windowSec : trig . windowSec ,
prePercent : trig . prePercent , mode : trig . mode , holdoffSec : trig . holdoffSec ,
} ) ;
@@ -1838,15 +1858,9 @@ function drawCursorLines(u, p) {
if ( vNorm === null ) return ;
const cy = u . valToPos ( vNorm , 'y' , true ) ;
if ( cy < bbox . top || cy > bbox . top + bbox . height ) return ;
// Un-transform normalized value back to real units for display
// y_norm = (y_raw - offset) / divValue → y_raw = y_norm * divValue + offset
const vs = sigVScale [ vsKeyFor ( p . id , key ) ] ;
let vReal = vNorm ;
if ( vs ) {
const dv = vs . _resolvedDiv || vs . divValue || 1 ;
const ofs = vs . _resolvedOffset != null ? vs . _resolvedOffset : ( vs . offset || 0 ) ;
vReal = vNorm * dv + ofs ;
}
// Calibrated value at the cursor time, from the raw source (matches
// the hover and the cursor readouts in every display mode).
const vReal = calibratedValueAt ( key , val ) ;
const tc = getSigStyle ( key ) . color ;
// Diamond marker at intersection
ctx . fillStyle = tc ;
@@ -1860,7 +1874,7 @@ function drawCursorLines(u, p) {
ctx . closePath ( ) ;
ctx . fill ( ) ;
// Value text next to diamond (real units)
const str = Math . abs ( vReal ) >= 10000 ? vReal . toExponential ( 2 ) : parseFloat ( vReal . toPrecision ( 4 ) ) . toString ( ) ;
const str = vReal === null ? '—' : ( Math . abs ( vReal ) >= 10000 ? vReal . toExponential ( 2 ) : parseFloat ( vReal . toPrecision ( 4 ) ) . toString ( ) ) ;
ctx . fillStyle = tc ;
ctx . font = '11px monospace' ;
const currentAlign = ctx . textAlign ;
@@ -1898,6 +1912,8 @@ function rulerRawValue(p, yNorm) {
// Draw the horizontal value rulers (called from the draw hook).
function drawRulerLines ( u , p ) {
if ( rulers . mode !== 'on' ) return ;
const rs = rulerState [ p . id ] ;
if ( ! rs ) return ;
const { ctx , bbox } = u ;
if ( ! bbox ) return ;
@@ -1926,8 +1942,8 @@ function drawRulerLines(u, p) {
ctx . restore ( ) ;
} ;
drawLine ( rulers . yA , 'rgba(166,227,161,0.85)' , 'Y1' ) ;
drawLine ( rulers . yB , 'rgba(243,139,168,0.85)' , 'Y2' ) ;
drawLine ( rs . yA , 'rgba(166,227,161,0.85)' , 'Y1' ) ;
drawLine ( rs . yB , 'rgba(243,139,168,0.85)' , 'Y2' ) ;
}
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
@@ -2133,8 +2149,9 @@ function createUPlot(p) {
const rect = p . uplot . over . getBoundingClientRect ( ) ;
const { min , max } = p . uplot . scales . y ;
const toY = val => rect . top + ( 1 - ( val - min ) / ( max - min ) ) * rect . height ;
if ( rulers . yA !== null && Math . abs ( clientY - toY ( rulers . yA ) ) <= CURSOR _SNAP _PX ) return 'A' ;
if ( rulers . yB !== null && Math . abs ( clientY - toY ( rulers . yB ) ) <= CURSOR _SNAP _PX ) return 'B ' ;
const rs = rulerState [ p . id ] ;
if ( rs && rs . yA !== null && Math . abs ( clientY - toY ( rs . yA ) ) <= CURSOR _SNAP _PX ) return 'A ' ;
if ( rs && rs . yB !== null && Math . abs ( clientY - toY ( rs . yB ) ) <= CURSOR _SNAP _PX ) return 'B' ;
return null ;
}
@@ -2171,8 +2188,10 @@ function createUPlot(p) {
// Set cursor position immediately on mousedown
if ( yTarget ) {
if ( yTarget === 'A' ) rulers . yA = _rulerValFromEvent ( e ) ;
else rulers . yB = _rulerValFromEvent ( e ) ;
rulers . plotId = p . id ; // the readout follows the plot whose rulers moved
const rs = getRulerState ( p . id ) ;
if ( yTarget === 'A' ) rs . yA = _rulerValFromEvent ( e ) ;
else rs . yB = _rulerValFromEvent ( e ) ;
} else if ( target === 'A' ) cursors . tA = _cursorValFromEvent ( e ) ;
else cursors . tB = _cursorValFromEvent ( e ) ;
updateCursorReadout ( ) ;
@@ -2180,8 +2199,9 @@ function createUPlot(p) {
const onMove = ev => {
if ( yTarget ) {
if ( yTarget === 'A' ) rulers . yA = _rulerValFromEvent ( ev ) ;
else rulers . yB = _rulerValFromEvent ( ev ) ;
const rs = getRulerState ( p . id ) ;
if ( yTarget === 'A' ) rs . yA = _rulerValFromEvent ( ev ) ;
else rs . yB = _rulerValFromEvent ( ev ) ;
} else if ( target === 'A' ) cursors . tA = _cursorValFromEvent ( ev ) ;
else cursors . tB = _cursorValFromEvent ( ev ) ;
updateCursorReadout ( ) ;
@@ -2753,7 +2773,19 @@ function updateCursorBtnVisibility() {
under one — so a zoom, a pan or a new capture can leave them outside the
viewport entirely, with no way to get them back: they are dragged by grabbing
their line, and an off-screen line cannot be grabbed. */
function resetRulers ( ) {
// Re-place every plot's rulers at the default ±2 divisions, like
// resetCursors re-places the vertical cursors.
plots . forEach ( p => {
const rs = getRulerState ( p . id ) ;
rs . yA = - 2 ; rs . yB = 2 ;
} ) ;
updateCursorReadout ( ) ;
cursorsDirty = true ;
}
function resetCursors ( ) {
resetRulers ( ) ;
const refPlot = plots . find ( p => p . uplot ) ;
if ( ! refPlot ) return ;
const { min , max } = refPlot . uplot . scales . x ;
@@ -2790,9 +2822,13 @@ document.getElementById('btn-ruler').addEventListener('click', () => {
rulers . mode = rulers . mode === 'off' ? 'on' : 'off' ;
const btn = document . getElementById ( 'btn-ruler' ) ;
btn . classList . toggle ( 'active' , rulers . mode === 'on' ) ;
if ( rulers . mode === 'on' && rulers . yA === null && rulers . yB === null ) {
// Auto-place at ±2 divisions from the centre on first use.
rulers . yA = - 2 ; rulers . yB = 2 ;
if ( rulers . mode === 'on' ) {
// Auto-place every plot at ±2 divisions from the centre on first use;
// afterwards each plot keeps its own positions.
plots . forEach ( pl => {
const rs = getRulerState ( pl . id ) ;
if ( rs . yA === null && rs . yB === null ) { rs . yA = - 2 ; rs . yB = 2 ; }
} ) ;
}
updateCursorReadout ( ) ;
cursorsDirty = true ;
@@ -2809,16 +2845,9 @@ function getValueAtCursor(p, t) {
if ( ! p . uplot || t === null ) return null ;
const key = plotActiveSignal [ p . id ] || ( p . traces . length === 1 ? p . traces [ 0 ] : null ) ;
if ( ! key ) return null ;
const idx = p . traces . indexOf ( key ) ;
if ( idx < 0 ) return null ;
const vNorm = interpAtTime ( p . uplot , idx + 1 , t ) ;
if ( vNorm === null ) return null ;
// Un-normalize: y_norm = (y_raw - offset) / divValue
const vs = sigVScale [ p . id + ':' + key ] ;
if ( ! vs ) return vNorm ;
const dv = vs . _resolvedDiv != null ? vs . _resolvedDiv : ( vs . divValue || 1 ) ;
const ofs = vs . _resolvedOffset != null ? vs . _resolvedOffset : ( vs . offset || 0 ) ;
return vNorm * dv + ofs ;
// Interpolate the raw wire value and apply the calibration explicitly, so
// cursor readouts match the hover in every display mode.
return calibratedValueAt ( key , t ) ;
}
// Update per-plot cursor value readouts (A, B, ΔV) for all plots.
@@ -2851,6 +2880,64 @@ function rawFromNorm(p, key, vNorm) {
const ofs = vs . _resolvedOffset != null ? vs . _resolvedOffset : ( vs . offset || 0 ) ;
return vNorm * dv + ofs ;
}
// Linear interpolation of a sorted (t, v) pair at absolute time tAbs. Returns
// null outside the data's range — never fabricated, so an export or readout
// cannot invent samples the signal never had.
function interpSortedRaw ( t , v , tAbs ) {
if ( ! t || t . length === 0 ) return null ;
if ( tAbs < t [ 0 ] || tAbs > t [ t . length - 1 ] ) return null ;
let lo = 0 , hi = t . length - 1 ;
while ( lo < hi ) { const m = ( lo + hi ) >> 1 ; if ( t [ m ] < tAbs ) lo = m + 1 ; else hi = m ; }
if ( lo === 0 ) return v [ 0 ] ? ? null ;
const t0 = t [ lo - 1 ] , t1 = t [ lo ] ;
const v0 = v [ lo - 1 ] , v1 = v [ lo ] ;
if ( v0 == null || v1 == null ) return v0 ? ? v1 ? ? null ;
return v0 + ( tAbs - t0 ) / ( t1 - t0 ) * ( v1 - v0 ) ;
}
// Binary-search linear interpolation of a circular buffer at time t.
function interpCircular ( buf , t ) {
if ( ! buf || buf . size === 0 ) return null ;
const { cap , size , head } = buf ;
const start = ( size === cap ) ? head : 0 ;
const physAt = k => ( start + k ) % cap ;
let lo = 0 , hi = size ;
while ( lo < hi ) { const m = ( lo + hi ) >> 1 ; if ( buf . t [ physAt ( m ) ] < t ) lo = m + 1 ; else hi = m ; }
if ( lo === 0 ) return buf . v [ physAt ( 0 ) ] ? ? null ;
if ( lo >= size ) return buf . v [ physAt ( size - 1 ) ] ? ? null ;
const t0 = buf . t [ physAt ( lo - 1 ) ] , t1 = buf . t [ physAt ( lo ) ] ;
const v0 = buf . v [ physAt ( lo - 1 ) ] , v1 = buf . v [ physAt ( lo ) ] ;
if ( v0 == null || v1 == null ) return v0 ? ? v1 ? ? null ;
return v0 + ( t - t0 ) / ( t1 - t0 ) * ( v1 - v0 ) ;
}
// Raw (uncalibrated) value of `key` at absolute time tAbs, from the best
// available raw source: trigger snapshot → fetched zoom data → live push
// buffer. All three store wire values, so calibration is applied here, at the
// point of display, exactly once.
function rawAtAbsTime ( key , tAbs ) {
if ( trig . snapshot ) {
const s = trig . snapshot [ key ] ;
if ( s && s . t . length ) { const v = interpSortedRaw ( s . t , s . v , tAbs ) ; if ( v != null ) return v ; }
}
for ( const p of plots ) {
const zd = zoomData [ p . id ] ;
if ( ! zd ) continue ;
const s = zd . signals [ key ] ;
if ( s && s . t . length ) { const v = interpSortedRaw ( s . t , s . v , tAbs ) ; if ( v != null ) return v ; }
}
const buf = buffers [ key ] ;
if ( buf && buf . size ) { const v = interpCircular ( buf , tAbs ) ; if ( v != null ) return v ; }
return null ;
}
// Calibrated value of `key` at axis time t. Under a trigger the axis is
// relative to the trigger instant, so convert to absolute first.
function calibratedValueAt ( key , t ) {
const tAbs = ( inTrigWindow ( ) && trig . trigTime != null ) ? trig . trigTime + t : t ;
const raw = rawAtAbsTime ( key , tAbs ) ;
return raw === null ? null : Calib . applyCal ( raw , calForKey ( key ) ) ;
}
function hideHoverReadout ( ) {
document . getElementById ( 'hover-readout' ) . style . display = 'none' ;
@@ -2870,13 +2957,14 @@ function showHoverReadout(p, e) {
const tStr = inTrigWindow ( ) ? fmtDuration ( t , span , true ) : fmtLiveTime ( t , span ) ;
let html = '<div class="hov-time">' + escHtml ( tStr ) + '</div>' ;
p . traces . forEach ( ( key , idx ) => {
const vNorm = interpAtTime ( p . uplot , idx + 1 , t ) ;
const name = key . includes ( ':' ) ? key . slice ( key . indexOf ( ':' ) + 1 ) : key ;
// 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 : '' ) ) ;
// Interpolate the raw wire value and apply the calibration explicitly,
// so the hover is correct in every display mode (analog, digital,
// mixed) and independent of the vscale state.
const vCal = calibratedValueAt ( key , t ) ;
const val = vCal === null ? '—'
: ( _fmtVal ( vCal ) + ( 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>' +
@@ -2894,17 +2982,25 @@ function showHoverReadout(p, e) {
el . style . top = Math . max ( 4 , y ) + 'px' ;
}
// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the firs t
// plot that has an active (or sole) signal.
// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the plo t
// whose rulers were last moved, falling back to the first plot with a signal.
function updateRulerReadout ( ) {
const box = document . getElementById ( 'ruler-readout' ) ;
const on = rulers . mode === 'on' ;
box . style . display = on ? '' : 'none' ;
if ( ! on ) return ;
const ref = plots . find ( p => p . uplot && p . traces . length > 0 &&
rulerRawValue ( p , 0 ) !== null ) ;
const conv = y => ( y === null || ! ref ) ? null : rulerRawValue ( ref , y ) ;
const vA = conv ( rulers . yA ) , vB = conv ( rulers . yB ) ;
let ref = null ;
if ( rulers . plotId !== null ) {
const pl = plots . find ( p => p . id === rulers . plotId ) ;
if ( pl && pl . uplot && pl . traces . length > 0 ) ref = pl ;
}
if ( ! ref ) {
ref = plots . find ( p => p . uplot && p . traces . length > 0 &&
rulerRawValue ( p , 0 ) !== null ) || null ;
}
const rs = ref ? rulerState [ ref . id ] : null ;
const conv = y => ( y === null || ! ref || ! rs ) ? null : rulerRawValue ( ref , y ) ;
const vA = conv ( rs ? rs . yA : null ) , vB = conv ( rs ? rs . yB : null ) ;
document . getElementById ( 'cur-y1' ) . textContent = 'Y1: ' + fmtVal ( vA ) ;
document . getElementById ( 'cur-y2' ) . textContent = 'Y2: ' + fmtVal ( vB ) ;
document . getElementById ( 'cur-dy' ) . textContent =
@@ -3355,23 +3451,42 @@ function initPlotCfgBar(plotId, p) {
/* ════════════════════════════════════════════════════════════════
Layout management
════════════════════════════════════════════════════════════════ */
// Returns the number of plot cells in a layout (cols × rows).
// Returns the number of plot cells in a layout. Custom layouts carry an
// explicit plotCount; uniform ones are cols × rows.
function layoutPlotCount ( cls ) {
const entry = LAYOUTS . find ( l => l [ 1 ] === cls ) ;
if ( entry ) {
if ( entry . length >= 5 ) return entry [ 4 ] ;
return entry [ 2 ] * entry [ 3 ] ;
}
const m = cls . match ( /^l(\d+)x(\d+)$/ ) ;
return m ? parseInt ( m [ 1 ] ) * parseInt ( m [ 2 ] ) : 1 ;
}
// Build a small SVG grid thumbnail for a given cols× rows layout.
function layoutSVG ( cols , rows ) {
// Build a small SVG grid thumbnail for a layout entry. Custom (non-uniform)
// layouts draw their own cell arrangement.
function layoutSVG ( entry ) {
const W = 28 , H = 20 , GAP = 1.5 , PAD = 1.5 ;
const cw = ( W - PAD * 2 - GAP * ( cols - 1 ) ) / cols ;
const ch = ( H - PAD * 2 - GAP * ( rows - 1 ) ) / rows ;
let rects = '' ;
for ( let r = 0 ; r < rows ; r ++ ) {
for ( let c = 0 ; c < cols ; c ++ ) {
const x = ( PAD + c * ( cw + GAP ) ) . toFixed ( 1 ) ;
const y = ( PAD + r * ( ch + GAP ) ) . toFixed ( 1 ) ;
rects + = ` <rect x=" ${ x } " y=" ${ y } " width=" ${ cw . toFixed ( 1 ) } " height=" ${ ch . toFixed ( 1 ) } " rx="1.5"/> ` ;
if ( entry [ 1 ] === 'l1p2' ) {
// 1+2: one full-width cell on top, two side by side below.
const cw = ( W - PAD * 2 - GAP ) / 2 ;
const ch = ( H - PAD * 2 - GAP ) / 2 ;
const y2 = ( PAD + ch + GAP ) . toFixed ( 1 ) ;
const x2 = ( PAD + cw + GAP ) . toFixed ( 1 ) ;
rects += ` <rect x=" ${ PAD } " y=" ${ PAD } " width=" ${ ( W - PAD * 2 ) . toFixed ( 1 ) } " height=" ${ ch . toFixed ( 1 ) } " rx="1.5"/> ` ;
rects += ` <rect x=" ${ PAD } " y=" ${ y2 } " width=" ${ cw . toFixed ( 1 ) } " height=" ${ ch . toFixed ( 1 ) } " rx="1.5"/> ` ;
rects += ` <rect x=" ${ x2 } " y=" ${ y2 } " width=" ${ cw . toFixed ( 1 ) } " height=" ${ ch . toFixed ( 1 ) } " rx="1.5"/> ` ;
} else {
const [ , , cols , rows ] = entry ;
const cw = ( W - PAD * 2 - GAP * ( cols - 1 ) ) / cols ;
const ch = ( H - PAD * 2 - GAP * ( rows - 1 ) ) / rows ;
for ( let r = 0 ; r < rows ; r ++ ) {
for ( let c = 0 ; c < cols ; c ++ ) {
const x = ( PAD + c * ( cw + GAP ) ) . toFixed ( 1 ) ;
const y = ( PAD + r * ( ch + GAP ) ) . toFixed ( 1 ) ;
rects += ` <rect x=" ${ x } " y=" ${ y } " width=" ${ cw . toFixed ( 1 ) } " height=" ${ ch . toFixed ( 1 ) } " rx="1.5"/> ` ;
}
}
}
return ` <svg xmlns="http://www.w3.org/2000/svg" width=" ${ W } " height=" ${ H } " viewBox="0 0 ${ W } ${ H } "> `
@@ -3401,7 +3516,7 @@ function applyLayout(cls) {
// Update button label
const btn = document . getElementById ( 'btn-layout' ) ;
if ( btn ) btn . innerHTML = layoutSVG ( cols , rows ) + ' <span>' + label + '</span> ▾' ;
if ( btn ) btn . innerHTML = layoutSVG ( entry ) + ' <span>' + label + '</span> ▾' ;
// Update active state in menu
document . querySelectorAll ( '.layout-menu-item' )
@@ -3436,11 +3551,12 @@ function applyLayout(cls) {
function buildLayoutMenu ( ) {
const menu = document . getElementById ( 'layout-menu' ) ;
LAYOUTS . forEach ( ( [ label , cls , cols , rows ] ) => {
LAYOUTS . forEach ( entry => {
const [ label , cls ] = entry ;
const item = document . createElement ( 'button' ) ;
item . className = 'layout-menu-item' + ( cls === currentLayout ? ' active' : '' ) ;
item . dataset . layout = cls ;
item . innerHTML = layoutSVG ( cols , rows ) + '<span>' + label + '</span>' ;
item . innerHTML = layoutSVG ( entry ) + '<span>' + label + '</span>' ;
item . addEventListener ( 'click' , ( ) => {
applyLayout ( cls ) ;
menu . classList . remove ( 'open' ) ;
@@ -3467,9 +3583,20 @@ function buildLayoutMenu() {
/* ════════════════════════════════════════════════════════════════
Export CSV (all plots) — fetches full-resolution data from ring
════════════════════════════════════════════════════════════════ */
// Shared busy state for the export dropdown: prevents re-entry and shows
// progress on the selector while a (possibly large) export runs.
let exportBusy = false ;
function setExportBusy ( busy ) {
exportBusy = busy ;
const sel = document . getElementById ( 'export-select' ) ;
if ( ! sel ) return ;
sel . disabled = busy ;
const ph = sel . querySelector ( 'option[value=""]' ) ;
if ( ph ) ph . textContent = busy ? '\u23f3 Exporting\u2026' : '\u23ea Export' ;
}
async function exportAllCSV ( ) {
const btn = document . getElementById ( 'btn-csv-all' ) ;
if ( btn . disabled ) return ;
if ( exportBusy ) return ;
const inTrigMode = trig . enabled && trig . snapshot !== null ;
@@ -3482,8 +3609,8 @@ async function exportAllCSV() {
let t0 , t1 , relOffset = 0 ;
if ( inTrigMode ) {
// Export the full trigger window around the trigger event.
t0 = trig . trigTime - trigPreSec ( ) ;
t1 = trig . trigTime + trigPostSec ( ) ;
t0 = trig . trigTime - activePreSec ( ) ;
t1 = trig . trigTime + activePostSec ( ) ;
relOffset = trig . trigTime ;
} else {
// Use the current zoom range if active, else the rolling window.
@@ -3504,60 +3631,52 @@ async function exportAllCSV() {
t1 = plotNow ;
}
}
if ( ! ( t1 > t0 ) ) return ;
// Show loading state.
const origLabel = btn . textContent ;
btn . textContent = '⏳ Downloading…' ;
btn . disabled = true ;
exportBusy = true ;
// Cap the export. A full window at a megasample rate is hundreds of MB raw
// (the old exact-timestamp merge exploded into millions of rows and crashed
// the tab); ask the hub for a min/max-decimated envelope — the same scope
// style reduction the live view uses — and cap the number of rows.
const BUDGET = 100000 ; // max rows per signal
setExportBusy ( true ) ;
// Fetch full-resolution ring data (n=0 → no decimation).
let ringSignals = null ;
try {
ringSignals = await wsZoomRequest ( t0 , t1 , 0 , keys ) ;
} catch ( e ) {
console . warn ( 'CSV export: ring fetch failed, falling back to push buffer' , e ) ;
} finally {
btn . textContent = origLabel ;
btn . disabled = false ;
if ( ! inTrigMode ) {
try {
ringSignals = await wsZoomRequest ( t0 , t1 , BUDGET , keys ) ;
} catch ( e ) {
console . warn ( 'CSV export: ring fetch failed, falling back to local data' , e ) ;
}
}
setExportBusy ( false ) ;
// Build p er-signal time/value arrays.
// Priority: ring buffer (full res) → trigger snapshot → push buffer.
// P er-signal raw source: hub ring (whole window, decimated) → trigger
// snapshot (already \u226420k pts) → local 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 ) } ;
if ( ! inTrigMode ) {
const rd = ringSignals && ringSignals [ key ] ;
if ( rd && rd . t && rd . t . length > 0 ) return { key , t : rd . t , v : rd . 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 - relOffset ) , v : Array . from ( raw . v ) } ;
return { key , t : raw . t , v : raw . v } ;
}
const buf = buffers [ key ] ; if ( ! buf ) return { t : [ ] , v : [ ] } ;
const sl = getBufferSliceRange ( buf , t0 , t1 ) ;
return { t : Array . from ( sl . t ) , v : Array . from ( sl . v ) } ;
const buf = buffers [ key ] ;
const sl = buf ? getBufferSliceRange ( buf , t0 , t1 ) : { t : new Float64Array ( 0 ) , v : new Float64Array ( 0 ) } ;
return { key , t : sl . t , v : sl . v } ;
} ) ;
const present = slices . filter ( s => s . t . length > 0 ) ;
if ( ! present . length ) return ;
// 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 ) ;
if ( ! sortedT . length ) return ;
// Master time grid = the signal with the most samples; every other signal is
// resampled onto it (linear, no extrapolation). Cells outside a signal's own
// span stay empty rather than being fabricated, so continuous signals export
// without holes and no value is invented.
let master = present [ 0 ] ;
present . forEach ( s => { if ( s . t . length > master . t . length ) master = s ; } ) ;
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, 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 cals = new Map ( keys . map ( k => [ k , calForKey ( k ) ] ) ) ;
const displayKeys = keys . map ( k => {
const name = k . includes ( ':' ) ? k . split ( ':' ) . slice ( 1 ) . join ( ':' ) : k ;
const u = unitForKey ( k ) ;
@@ -3566,10 +3685,21 @@ async function exportAllCSV() {
} ) ;
const timeCol = '"' + ( inTrigMode ? 'time_rel_s' : 'time_s' ) + '"' ;
const hdr = [ timeCol , ... displayKeys ] . join ( ',' ) ;
const rows = sortedT . map ( t =>
[ t . toFixed ( 9 ) , ... lookups . map ( ( lk , i ) =>
lk . has ( t ) ? Calib . applyCal ( lk . get ( t ) , cals [ i ] ) : '' ) ] . join ( ',' )
) ;
const rows = new Array ( master . t . length ) ;
for ( let i = 0 ; i < master . t . length ; i ++ ) {
const tAbs = master . t [ i ] ;
const cells = present . map ( s => {
if ( s === master ) {
return Calib . applyCal ( master . v [ i ] , cals . get ( s . key ) ) ;
}
const v = interpSortedRaw ( s . t , s . v , tAbs ) ;
return v === null ? '' : Calib . applyCal ( v , cals . get ( s . key ) ) ;
} ) ;
const tt = inTrigMode ? tAbs - relOffset : tAbs ;
rows [ i ] = [ tt . toFixed ( 9 ) , ... cells ] . join ( ',' ) ;
}
const blob = new Blob ( [ hdr + '\n' + rows . join ( '\n' ) ] , { type : 'text/csv' } ) ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
@@ -3958,6 +4088,7 @@ function onSources(msg) {
} ) ;
buildSidebar ( ) ;
if ( statsOpen ) _refreshStatsSelector ( ) ;
maybeRestoreViewLate ( ) ;
}
function addSourceWS ( label , addr , multicastGroup , dataPort ) {
@@ -4582,7 +4713,303 @@ initSignalMenu();
const cb = document . getElementById ( 'cb-monotonic' ) ;
if ( cb ) cb . checked = localStorage . getItem ( 'udpscope.monotonic' ) === '1' ;
}
document . getElementById ( 'btn-csv-all' ) . addEventListener ( 'click' , exportAllCSV ) ;
// Export every stored sample of the plotted signals as a Parquet file, served
// by the Go hub's /api/export. Full resolution (no decimation) and hole-free
// (each signal keeps its own timestamps — long format). The file can be huge
// (hundreds of MB at high rates), so stream it to disk when the File System
// Access API is available instead of holding it in a Blob.
async function exportParquet ( ) {
if ( exportBusy ) return ;
const inTrigMode = trig . enabled && trig . snapshot !== null ;
const keys = [ ] ;
plots . forEach ( p => p . traces . forEach ( k => { if ( ! keys . includes ( k ) ) keys . push ( k ) ; } ) ) ;
if ( ! keys . length ) return ;
// Same range resolution as the CSV export.
let t0 , t1 ;
if ( inTrigMode ) {
t0 = trig . trigTime - activePreSec ( ) ;
t1 = trig . trigTime + activePostSec ( ) ;
} else {
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 ;
}
}
if ( ! ( t1 > t0 ) ) return ;
exportBusy = true ;
setExportBusy ( true ) ;
try {
const url = '/api/export?t0=' + t0 . toFixed ( 9 ) + '&t1=' + t1 . toFixed ( 9 ) +
'&signals=' + encodeURIComponent ( keys . join ( ',' ) ) ;
const resp = await fetch ( url ) ;
if ( ! resp . ok ) {
alert ( 'Parquet export failed (HTTP ' + resp . status + ').\n\n' +
'The /api/export endpoint is provided by the Go hub; the C++ ' +
'StreamHub does not serve it.' ) ;
return ;
}
const filename = 'signals_' + Date . now ( ) + '.parquet' ;
if ( window . showSaveFilePicker && resp . body ) {
try {
const handle = await window . showSaveFilePicker ( {
suggestedName : filename ,
types : [ { description : 'Parquet' , accept : { 'application/vnd.apache.parquet' : [ '.parquet' ] } } ] ,
} ) ;
const writable = await handle . createWritable ( ) ;
await resp . body . pipeTo ( writable ) ;
return ;
} catch ( e ) {
if ( e && e . name === 'AbortError' ) return ; // user cancelled the picker
console . warn ( 'parquet export: file picker failed, falling back to Blob' , e ) ;
}
}
const blob = await resp . blob ( ) ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = filename ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
} catch ( e ) {
console . warn ( 'parquet export failed' , e ) ;
alert ( 'Parquet export failed: ' + e . message ) ;
} finally {
setExportBusy ( false ) ;
}
}
// Export dropdown: dispatch on selection, then reset to the placeholder so the
// same format can be chosen again.
document . getElementById ( 'export-select' ) . addEventListener ( 'change' , ( ) => {
const sel = document . getElementById ( 'export-select' ) ;
const fmt = sel . value ;
sel . value = '' ;
if ( fmt === 'csv' ) exportAllCSV ( ) ;
else if ( fmt === 'parquet' ) exportParquet ( ) ;
} ) ;
/* ════════════════════════════════════════════════════════════════
View-state persistence (cookie)
════════════════════════════════════════════════════════════════ */
// The whole client view — layout, plots (traces/titles/modes), window, trigger
// configuration, rulers, sources — is serialised into one cookie so a reload
// restores the previous view. Cookies are size-limited, so the state degrades
// gracefully (rulers → trigger → sources → traces) when it would not fit.
const VIEW _COOKIE = 'udpscope.view' ;
const VIEW _COOKIE _MAX = 3500 ; // encoded chars; browsers cap cookies at ~4 KiB
function packViewState ( ) {
const state = {
v : 1 ,
windowSec : windowSec ,
layout : currentLayout ,
plots : plots . map ( p => ( {
title : p . title ,
mode : p . mode ,
traces : p . traces . map ( k => {
const colon = k . indexOf ( ':' ) ;
const name = colon >= 0 ? k . slice ( colon + 1 ) : k ;
return { key : k , label : srcLabelForKey ( k ) , name } ;
} ) ,
} ) ) ,
trig : {
enabled : trig . enabled , signal : trig . signal , edge : trig . edge ,
threshold : trig . threshold , windowSec : trig . windowSec ,
prePercent : trig . prePercent , mode : trig . mode , holdoffSec : trig . holdoffSec ,
} ,
rulers : {
mode : rulers . mode ,
plotId : plots . findIndex ( p => p . id === rulers . plotId ) ,
states : plots . map ( p => {
const rs = rulerState [ p . id ] ;
return rs ? { yA : rs . yA , yB : rs . yB } : { yA : null , yB : null } ;
} ) ,
} ,
sources : Object . values ( sourcesMap ) . map ( s => ( {
label : s . label || s . addr || s . id , addr : s . addr ,
} ) ) ,
} ;
let s = JSON . stringify ( state ) ;
const tooBig = ( ) => encodeURIComponent ( s ) . length > VIEW _COOKIE _MAX ;
if ( tooBig ( ) ) { delete state . rulers ; s = JSON . stringify ( state ) ; }
if ( tooBig ( ) ) { delete state . trig ; s = JSON . stringify ( state ) ; }
if ( tooBig ( ) ) { delete state . sources ; s = JSON . stringify ( state ) ; }
if ( tooBig ( ) ) {
state . plots = state . plots . map ( p => ( { title : p . title , mode : p . mode } ) ) ;
s = JSON . stringify ( state ) ;
}
if ( tooBig ( ) ) { state . plots = [ ] ; s = JSON . stringify ( state ) ; }
return s ;
}
// Saves are gated until the saved view has been re-applied (phase 2) or the
// grace timeout fires: otherwise the very first periodic save would overwrite
// the cookie with the not-yet-restored (empty) state and destroy it.
let _viewSaveReady = false ;
function saveViewState ( ) {
if ( ! _viewSaveReady ) return ;
try {
const s = packViewState ( ) ;
document . cookie = VIEW _COOKIE + '=' + encodeURIComponent ( s ) +
'; path=/; max-age=31536000; SameSite=Lax' ;
} catch ( e ) {
console . warn ( 'view cookie save failed' , e ) ;
}
}
function readViewState ( ) {
try {
const prefix = VIEW _COOKIE + '=' ;
const m = document . cookie . split ( '; ' ) . find ( c => c . startsWith ( prefix ) ) ;
if ( ! m ) return null ;
const st = JSON . parse ( decodeURIComponent ( m . slice ( prefix . length ) ) ) ;
return ( st && st . v === 1 ) ? st : null ;
} catch ( e ) {
return null ;
}
}
// Phase 1 (init): layout, plot cards (titles/modes), window, rulers. Traces and
// the trigger need sources/signals loaded, so they are applied in phase 2.
function restoreViewState ( ) {
const st = readViewState ( ) ;
if ( ! st ) return ;
if ( st . layout && LAYOUTS . some ( l => l [ 1 ] === st . layout ) ) applyLayout ( st . layout ) ;
const plotState = st . plots || [ ] ;
plotState . forEach ( ( ps , i ) => {
const p = plots [ i ] ;
if ( ! p ) return ;
if ( ps . title && ps . title !== 'Plot ' + p . id ) {
p . title = ps . title ;
const tEl = document . getElementById ( 'ptitle-' + p . id ) ;
if ( tEl ) tEl . textContent = ps . title ;
const inp = document . querySelector ( '#pcfg-' + p . id + ' .pcfg-title-input' ) ;
if ( inp ) inp . value = ps . title ;
}
if ( ps . mode && ps . mode !== p . mode ) {
p . mode = ps . mode ;
document . querySelectorAll ( '#pcfg-' + p . id + ' .pcfg-mode-btn' )
. forEach ( b => b . classList . toggle ( 'active' , b . dataset . mode === ps . mode ) ) ;
}
} ) ;
if ( st . windowSec != null ) {
windowSec = st . windowSec ;
const sel = document . getElementById ( 'window-select' ) ;
if ( sel && [ ... sel . options ] . some ( o => o . value === String ( st . windowSec ) ) ) {
sel . value = String ( st . windowSec ) ;
}
}
if ( st . rulers ) {
rulers . mode = st . rulers . mode === 'on' ? 'on' : 'off' ;
const btn = document . getElementById ( 'btn-ruler' ) ;
if ( btn ) btn . classList . toggle ( 'active' , rulers . mode === 'on' ) ;
( st . rulers . states || [ ] ) . forEach ( ( rs , i ) => {
const p = plots [ i ] ;
if ( ! p || ! rs ) return ;
const cur = getRulerState ( p . id ) ;
cur . yA = rs . yA ; cur . yB = rs . yB ;
} ) ;
if ( st . rulers . plotId != null && plots [ st . rulers . plotId ] ) {
rulers . plotId = plots [ st . rulers . plotId ] . id ;
}
}
}
// Rebuild a saved trace key against the current sources: source ids change
// across restarts, so match by label and fall back to the saved id-key.
function restoreTraceKey ( entry ) {
const src = Object . values ( sourcesMap ) . find ( s => ( s . label || s . id ) === entry . label ) ;
return src ? ( src . id + ':' + entry . name ) : entry . key ;
}
// Phase 2 (first sources + signals): reconcile sources, re-apply traces, then
// the trigger configuration and the window to the hub.
let _viewLateRestored = false ;
function maybeRestoreViewLate ( ) {
if ( _viewLateRestored ) return ;
const st = readViewState ( ) ;
if ( ! st ) { _viewLateRestored = true ; return ; }
// Add any saved sources the hub does not already have (it persists its own).
const known = Object . values ( sourcesMap ) . map ( s => ( s . label || s . addr || s . id ) + '\u0000' + s . addr ) ;
let added = false ;
( st . sources || [ ] ) . forEach ( sv => {
const k = ( sv . label || sv . addr ) + '\u0000' + ( sv . addr || '' ) ;
if ( ! known . includes ( k ) ) { addSourceWS ( sv . label , sv . addr , sv . multicastGroup , sv . dataPort ) ; added = true ; }
} ) ;
if ( added ) return ; // re-enter when the new sources appear
// Traces and the trigger selector need at least one source with signals.
if ( ! Object . values ( sourcesMap ) . some ( s => ( s . signals || [ ] ) . length > 0 ) ) return ;
_viewLateRestored = true ;
_viewSaveReady = true ;
( st . plots || [ ] ) . forEach ( ( ps , i ) => {
const p = plots [ i ] ;
if ( ! p ) return ;
( ps . traces || [ ] ) . forEach ( t => addTraceTo ( p . id , restoreTraceKey ( t ) ) ) ;
} ) ;
if ( st . windowSec != null ) {
windowSec = st . windowSec ;
sendWindow ( ) ;
}
const t = st . trig ;
if ( t ) {
trig . edge = t . edge || trig . edge ;
if ( t . threshold != null ) trig . threshold = t . threshold ;
if ( t . windowSec != null ) trig . windowSec = t . windowSec ;
if ( t . prePercent != null ) trig . prePercent = t . prePercent ;
trig . mode = t . mode || trig . mode ;
if ( t . holdoffSec != null ) trig . holdoffSec = t . holdoffSec ;
trig . signal = t . signal || '' ;
const el = id => document . getElementById ( id ) ;
if ( el ( 'trig-edge' ) ) el ( 'trig-edge' ) . value = trig . edge ;
if ( el ( 'trig-window' ) ) el ( 'trig-window' ) . value = String ( trig . windowSec ) ;
if ( el ( 'trig-mode' ) ) el ( 'trig-mode' ) . value = trig . mode ;
if ( el ( 'trig-holdoff' ) ) el ( 'trig-holdoff' ) . value = trig . holdoffSec ;
if ( el ( 'trig-pre' ) ) el ( 'trig-pre' ) . value = String ( trig . prePercent ) ;
if ( el ( 'trig-pre-val' ) ) el ( 'trig-pre-val' ) . textContent = trig . prePercent + '%' ;
refreshTrigThresholdField ( ) ;
const selSig = document . getElementById ( 'trig-signal' ) ;
if ( selSig && trig . signal ) {
const base = trig . signal . replace ( /\[\d+\]$/ , '' ) ;
if ( [ ... selSig . options ] . some ( o => o . value === base ) ) selSig . value = base ;
}
if ( t . enabled ) openTrigBar ( true ) ;
else updateTrigStatusBadge ( 'idle' ) ;
}
}
// Periodic save keeps the cookie current without wiring every control; the
// pagehide save captures the final state on close/reload.
setInterval ( saveViewState , 3000 ) ;
window . addEventListener ( 'pagehide' , saveViewState ) ;
// Hub down / never connected: stop gating after 10 s so layout, window and
// rulers still persist even though traces and the trigger could not be
// restored.
setTimeout ( ( ) => { _viewSaveReady = true ; } , 10000 ) ;
document . getElementById ( 'history-badge' ) . addEventListener ( 'click' , toggleHistoryPanel ) ;
document . getElementById ( 'btn-hist-cancel' ) . addEventListener ( 'click' , toggleHistoryPanel ) ;
document . getElementById ( 'btn-hist-apply' ) . addEventListener ( 'click' , applyHistoryBudget ) ;
@@ -4592,6 +5019,7 @@ document.getElementById('stats-source-sel').addEventListener('change', e => {
statsSelectedSrc = e . target . value || null ;
renderStats ( ) ;
} ) ;
restoreViewState ( ) ;
resolveHub ( ) . then ( connectWS ) ;
requestAnimationFrame ( renderDirtyPlots ) ;
fetch ( '/version' ) . then ( r => r . text ( ) ) . then ( v => {