@@ -2,13 +2,35 @@
/* ════════════════════════════════════════════════════════════════
Constants
════════════════════════════════════════════════════════════════ */
const DEFAULT _CAP = 10_000 ;
// Temporal signals receive 50 pts/tick × 30 Hz = 1 500 pts/s from the server.
// 50 000 cap → ~33 s of rolling history; well beyond the max supported window .
// Zoom uses /api/zoom (server ring buffer) so the cap only affects rolling display.
const TEMPORAL _CAP = 50_000 ;
// Largest window option in the UI (seconds). Buffers are pre-sized to hold this much history.
const MAX _WINDOW _SEC = 60 ;
// Hard ceiling per buffer (~9.6 MB per signal at Float64 t+v) .
const MAX _CAP = 600_000 ;
// Default capacity for signals whose rate is unknown: covers 60 s at ~1.5 kHz.
const DEFAULT _CAP = 100_000 ;
// Temporal signals receive up to 50 pts/tick × 200 Hz = 10 000 pts/s.
// TEMPORAL_CAP covers the full 60-second window for rates up to ~8 kHz.
const TEMPORAL _CAP = Math . min ( MAX _CAP , 500_000 ) ;
const LTTB _MIN = 200 ; // never decimate below this many points
// Compute a buffer capacity that holds MAX_WINDOW_SEC * 1.5 of data at rateHz.
function bufferCapForRate ( rateHz ) {
if ( ! rateHz || rateHz <= 0 ) return DEFAULT _CAP ;
return Math . min ( MAX _CAP , Math . ceil ( rateHz * MAX _WINDOW _SEC * 1.5 ) ) ;
}
// Return a new, larger circular buffer that preserves all existing samples.
function growBuffer ( buf , newCap ) {
if ( newCap <= buf . cap ) return buf ;
const nb = makeBuffer ( newCap ) ;
const start = buf . size === buf . cap ? buf . head : 0 ;
for ( let i = 0 ; i < buf . size ; i ++ ) {
const idx = ( start + i ) % buf . cap ;
pushBuffer ( nb , buf . t [ idx ] , buf . v [ idx ] ) ;
}
return nb ;
}
const TRACE _COLORS = [
'#89b4fa' , '#a6e3a1' , '#f38ba8' , '#fab387' , '#cba6f7' ,
'#94e2d5' , '#89dceb' , '#b4befe' , '#f9e2af' , '#f5c2e7' ,
@@ -63,7 +85,7 @@ function setSigStyle(key, updates) {
// vsKey: compound key "plotId:signalKey" so same signal in different plots is independent.
function getVScale ( plotId , key ) {
const vsKey = plotId + ':' + key ;
if ( ! sigVScale [ vsKey ] ) sigVScale [ vsKey ] = { mode : 'auto' , divValue : 1 , offset : 0 , screenPos : 0 , _resolvedDiv : null , _resolvedOffset : null } ;
if ( ! sigVScale [ vsKey ] ) sigVScale [ vsKey ] = { mode : 'auto' , divValue : 1 , offset : 0 , screenPos : 0 , _resolvedDiv : null , _resolvedOffset : null , digitalInMixed : false } ;
return sigVScale [ vsKey ] ;
}
@@ -112,9 +134,47 @@ function resolveVScale(plotId, key, rawY) {
return { divValue , offset , screenPos } ;
}
// Mixed mode: each signal occupies a fixed band; within that band it is either
// quantized (digital) or auto-scaled (analog) based on vs.digitalInMixed.
function applyMixedNorm ( p , yArrays ) {
const n = p . traces . length ;
if ( n === 0 ) return yArrays ;
const bandH = 8 / n ;
return yArrays . map ( ( rawY , ki ) => {
const key = p . traces [ ki ] ;
const vs = getVScale ( p . id , key ) ;
const centerY = 4 - ( ki + 0.5 ) * bandH ;
const hi = centerY + bandH * 0.35 ;
const lo = centerY - bandH * 0.35 ;
let min = Infinity , max = - Infinity ;
for ( let i = 0 ; i < rawY . length ; i ++ ) {
const v = rawY [ i ] ; if ( v != null && isFinite ( v ) ) { if ( v < min ) min = v ; if ( v > max ) max = v ; }
}
const out = new Float64Array ( rawY . length ) ;
if ( vs . digitalInMixed ) {
const threshold = isFinite ( min ) ? ( min + max ) / 2 : 0.5 ;
for ( let i = 0 ; i < rawY . length ; i ++ ) {
const v = rawY [ i ] ;
out [ i ] = ( v == null || ! isFinite ( v ) ) ? NaN : ( v >= threshold ? hi : lo ) ;
}
} else {
if ( ! isFinite ( min ) ) { min = 0 ; max = 1 ; }
if ( min === max ) { min -= 1 ; max += 1 ; }
const range = max - min , bandRange = hi - lo ;
for ( let i = 0 ; i < rawY . length ; i ++ ) {
const v = rawY [ i ] ;
out [ i ] = ( v == null || ! isFinite ( v ) ) ? NaN : lo + ( v - min ) / range * bandRange ;
}
}
return out ;
} ) ;
}
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos.
function applyVScaleNorm ( p , yArrays ) {
if ( p . mode === 'digital' ) return applyDigitalNorm ( p , yArrays ) ;
if ( p . mode === 'mixed' ) return applyMixedNorm ( p , yArrays ) ;
return yArrays . map ( ( rawY , ki ) => {
const key = p . traces [ ki ] ;
const { divValue , offset , screenPos } = resolveVScale ( p . id , key , rawY ) ;
@@ -127,6 +187,32 @@ function applyVScaleNorm(p, yArrays) {
} ) ;
}
// Digital mode: quantize each signal to lo/hi within its own horizontal band.
// Signals are arranged top-to-bottom matching badge order (index 0 = top).
function applyDigitalNorm ( p , yArrays ) {
const n = p . traces . length ;
if ( n === 0 ) return yArrays ;
const bandH = 8 / n ; // total Y span is 8 divisions (-4 to +4)
return yArrays . map ( ( rawY , ki ) => {
// Top-down: signal 0 is at top (highest Y value)
const centerY = 4 - ( ki + 0.5 ) * bandH ;
const hi = centerY + bandH * 0.35 ;
const lo = centerY - bandH * 0.35 ;
// Threshold: midpoint of min/max
let min = Infinity , max = - Infinity ;
for ( let i = 0 ; i < rawY . length ; i ++ ) {
const v = rawY [ i ] ; if ( v != null && isFinite ( v ) ) { if ( v < min ) min = v ; if ( v > max ) max = v ; }
}
const threshold = isFinite ( min ) ? ( min + max ) / 2 : 0.5 ;
const out = new Float64Array ( rawY . length ) ;
for ( let i = 0 ; i < rawY . length ; i ++ ) {
const v = rawY [ i ] ;
out [ i ] = ( v == null || ! isFinite ( v ) ) ? NaN : ( v >= threshold ? hi : lo ) ;
}
return out ;
} ) ;
}
// Set the active (Y-axis-labelled) signal for a plot and update badge highlights.
function setActiveSig ( plotId , key ) {
if ( key === null || key === undefined ) {
@@ -139,6 +225,7 @@ function setActiveSig(plotId, key) {
b . classList . toggle ( 'sig-badge-active' , key != null && b . dataset . key === key ) ) ;
const p = plots . find ( q => q . id === plotId ) ;
if ( p && p . uplot ) p . uplot . redraw ( false ) ;
updatePlotCursorReadouts ( ) ;
}
// Mark plots containing key dirty and refresh badge vscale text.
@@ -205,6 +292,9 @@ const LAYOUTS = [
[ '3× 1' , 'l3x1' , 3 , 1 ] , [ '2× 2' , 'l2x2' , 2 , 2 ] , [ '1× 4' , 'l1x4' , 1 , 4 ] , [ '4× 1' , 'l4x1' , 4 , 1 ] ,
] ;
let currentLayout = 'l1x1' ;
let colFrs = [ 1 ] ; // fractional column sizes (sum = cols)
let rowFrs = [ 1 ] ; // fractional row sizes (sum = rows)
let _gridCols = 1 , _gridRows = 1 ;
/* ════════════════════════════════════════════════════════════════
Trigger state
@@ -300,9 +390,10 @@ function onConfig(msg) {
newSigs . forEach ( sig => {
const n = numElements ( sig ) ;
const base = prefix + sig . name ;
const sigCap = bufferCapForRate ( sig . samplingRate ) ;
if ( isTemporal ( sig ) ) { buffers [ base ] = makeBuffer ( TEMPORAL _CAP ) ; }
else if ( n === 1 ) { buffers [ base ] = makeBuffer ( ) ; }
else { for ( let i = 0 ; i < n ; i ++ ) buffers [ base + '[' + i + ']' ] = makeBuffer ( ) ; }
else if ( n === 1 ) { buffers [ base ] = makeBuffer ( sigCap ) ; }
else { for ( let i = 0 ; i < n ; i ++ ) buffers [ base + '[' + i + ']' ] = makeBuffer ( sigCap ) ; }
} ) ;
if ( trig . signal && trig . signal . startsWith ( prefix ) ) {
trigDisarm ( ) ; trig . snapshot = null ; trig . prevVal = null ;
@@ -382,6 +473,13 @@ function onBinaryData(buf) {
bufObj = makeBuffer ( n > 100 ? TEMPORAL _CAP : DEFAULT _CAP ) ;
buffers [ fullKey ] = bufObj ;
}
// If the buffer was created before the CONFIG message arrived (or with an
// underestimated capacity), grow it now that we know the signal's rate.
const detectedRate = getKeySamplingRate ( fullKey ) ;
if ( detectedRate > 0 ) {
const needed = bufferCapForRate ( detectedRate ) ;
if ( needed > bufObj . cap ) { bufObj = growBuffer ( bufObj , needed ) ; 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 ;
@@ -565,6 +663,64 @@ function getBufferSliceRange(buf, t0, t1) {
}
return { t : outT , v : outV } ;
}
// Like getBufferSliceRange but also includes the nearest point just outside each
// boundary so that a line is always drawn across the visible area even when the
// zoom window contains only 0 or 1 samples.
function getBufferSliceRangeWithBrackets ( buf , t0 , t1 ) {
if ( buf . size === 0 ) return { t : new Float64Array ( 0 ) , v : new Float64Array ( 0 ) } ;
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 ) ] < t0 ) lo = m + 1 ; else hi = m ; }
const kStart = lo ;
lo = kStart ; hi = size ;
while ( lo < hi ) { const m = ( lo + hi ) >>> 1 ; if ( buf . t [ physAt ( m ) ] <= t1 ) lo = m + 1 ; else hi = m ; }
const kEnd = lo ;
// Expand by one on each side for bracketing points.
const kFrom = Math . max ( 0 , kStart - 1 ) ;
const kTo = Math . min ( size , kEnd + 1 ) ;
const len = kTo - kFrom ;
if ( len <= 0 ) return { t : new Float64Array ( 0 ) , v : new Float64Array ( 0 ) } ;
const outT = new Float64Array ( len ) , outV = new Float64Array ( len ) ;
for ( let i = 0 ; i < len ; i ++ ) {
const idx = physAt ( kFrom + i ) ;
outT [ i ] = buf . t [ idx ] ; outV [ i ] = buf . v [ idx ] ;
}
return { t : outT , v : outV } ;
}
// Supplement sparse fetched signal data ({t, v} Float64Arrays) with the nearest
// bracketing points from the local circular buffer, so lines are always drawn
// across the zoom window even if the server returned 0 or 1 points.
function supplementWithBrackets ( sd , buf , t0 , t1 ) {
if ( ! buf || buf . size === 0 ) return sd ;
if ( sd && sd . t . length >= 2 ) return sd ; // already enough points
const { size , head , cap } = 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 ) ] < t0 ) lo = m + 1 ; else hi = m ; }
const kStart = lo ;
lo = kStart ; hi = size ;
while ( lo < hi ) { const m = ( lo + hi ) >>> 1 ; if ( buf . t [ physAt ( m ) ] <= t1 ) lo = m + 1 ; else hi = m ; }
const kEnd = lo ;
const leftK = kStart > 0 ? kStart - 1 : - 1 ;
const rightK = kEnd < size ? kEnd : - 1 ;
const tArr = [ ] , vArr = [ ] ;
if ( leftK >= 0 ) { tArr . push ( buf . t [ physAt ( leftK ) ] ) ; vArr . push ( buf . v [ physAt ( leftK ) ] ) ; }
if ( sd ) { for ( let i = 0 ; i < sd . t . length ; i ++ ) { tArr . push ( sd . t [ i ] ) ; vArr . push ( sd . v [ i ] ) ; } }
if ( rightK >= 0 ) { tArr . push ( buf . t [ physAt ( rightK ) ] ) ; vArr . push ( buf . v [ physAt ( rightK ) ] ) ; }
if ( tArr . length === 0 ) return sd ;
return { t : Float64Array . from ( tArr ) , v : Float64Array . from ( vArr ) } ;
}
// getGlobalNow returns the reference "now" for the rolling window.
// Always anchors to the newest timestamp found in any buffer so the rolling
// window tracks real data regardless of any clock skew between the Go server
@@ -790,17 +946,26 @@ async function doZoomFetch(t0, t1) {
}
// Build uPlot data arrays from server-fetched hi-res signal data.
// Sparse signals (0 or 1 pts in range) are supplemented with local buffer
// bracket points so a line is always drawn across the visible area.
function buildDataFromFetched ( p , fetchedSignals , targetPts ) {
const t0 = p . xRange ? p . xRange [ 0 ] : - Infinity ;
const t1 = p . xRange ? p . xRange [ 1 ] : Infinity ;
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 ;
let sd = fetchedSignals [ key ] ;
if ( ! sd || ! sd . t || sd . t . length < 2 ) sd = supplementWithBrackets ( sd , buffers [ key ] , t0 , t1 ) ;
if ( ! sd || ! 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 ] ;
let masterSd = fetchedSignals [ masterKey ] ;
if ( ! masterSd || masterSd . t . length < 2 )
masterSd = supplementWithBrackets ( masterSd , buffers [ masterKey ] , t0 , t1 ) ;
if ( ! masterSd || ! masterSd . t . length )
return [ new Float64Array ( 0 ) , ... p . traces . map ( ( ) => new Float64Array ( 0 ) ) ] ;
@@ -809,7 +974,8 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) {
const yArrays = [ ] ;
for ( const key of p . traces ) {
if ( key === masterKey ) { yArrays . push ( dec . v ) ; continue ; }
const sd = fetchedSignals [ key ] ;
let sd = fetchedSignals [ key ] ;
if ( ! sd || sd . t . length < 2 ) sd = supplementWithBrackets ( sd , buffers [ key ] , t0 , t1 ) ;
if ( ! sd || ! sd . t . length ) { yArrays . push ( new Float64Array ( sharedT . length ) ) ; continue ; }
yArrays . push ( resampleLinear ( sd . t , sd . v , sharedT ) ) ;
}
@@ -959,10 +1125,32 @@ function interpAtTime(u, si, t) {
return v0 + ( t - t0 ) / ( t1 - t0 ) * ( v1 - v0 ) ;
}
// Draw horizontal separator lines between signal bands in digital/mixed mode.
function drawBandSeparators ( u , p ) {
if ( ! u . bbox || ( p . mode !== 'digital' && p . mode !== 'mixed' ) ) return ;
const n = p . traces . length ;
if ( n < 2 ) return ;
const { ctx , bbox } = u ;
const dpr = window . devicePixelRatio || 1 ;
const bandH = 8 / n ;
ctx . save ( ) ;
ctx . strokeStyle = 'rgba(127,132,156,0.25)' ;
ctx . lineWidth = dpr ;
for ( let i = 1 ; i < n ; i ++ ) {
const yNorm = 4 - i * bandH ; // boundary between band i-1 and band i
const yPx = u . valToPos ( yNorm , 'y' , true ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( bbox . left , yPx ) ;
ctx . lineTo ( bbox . left + bbox . width , yPx ) ;
ctx . stroke ( ) ;
}
ctx . restore ( ) ;
}
// Redraw the active signal's line on top of all series with a wider stroke, so it
// visually appears in the foreground regardless of series draw order.
function drawActiveSeries ( u , p ) {
if ( ! u . bbox ) return ;
if ( ! u . bbox || p . mode === 'digital' || p . mode === 'mixed' ) return ;
const activeKey = plotActiveSignal [ p . id ] ;
if ( ! activeKey ) return ;
const idx = p . traces . indexOf ( activeKey ) ;
@@ -995,7 +1183,7 @@ function drawActiveSeries(u, p) {
// Draw offset position markers (right-pointing triangles) on the left edge of the plot
// for each signal. Active signal marker is larger and outlined in white.
function drawOffsetMarkers ( u , p ) {
if ( ! u . bbox ) return ;
if ( ! u . bbox || p . mode === 'digital' || p . mode === 'mixed' ) return ;
const { ctx , bbox } = u ;
const dpr = window . devicePixelRatio || 1 ;
@@ -1100,8 +1288,10 @@ function drawCursorLines(u, p) {
ctx . fillText ( label , px + 14 , bbox . top + 2 ) ;
// Per-trace: diamond at crossing point + value label
if ( p ) {
const DSZ = 5 ; // diamond half-size in px
const activeKey = plotActiveSignal [ p . id ] ;
p . traces . forEach ( ( key , idx ) => {
const isActive = key === activeKey || ( ! activeKey && p . traces . length === 1 ) ;
const DSZ = isActive ? 9 : 5 ; // diamond half-size in px
const vNorm = interpAtTime ( u , idx + 1 , val ) ;
if ( vNorm === null ) return ;
const cy = u . valToPos ( vNorm , 'y' , true ) ;
@@ -1175,16 +1365,19 @@ function computePlotNow(p) {
// Build uPlot opts for a given plot object
function makeUPlotOpts ( p , inTrigMode ) {
const isBanded = p . mode === 'digital' || p . mode === 'mixed' ;
const seriesArr = [ { } ] ; // time (index 0)
p . traces . forEach ( key => {
const style = getSigStyle ( key ) ;
const pathsFn = makeSeriesPath ( key ) ;
const pathsFn = isBanded ? null : makeSeriesPath ( key ) ;
const vs = isBanded ? getVScale ( p . id , key ) : null ;
const isDigSig = p . mode === 'digital' || ( p . mode === 'mixed' && vs && vs . digitalInMixed ) ;
seriesArr . push ( {
label : key ,
stroke : style . color ,
width : style . width ,
points : { show : style . marker === 'circle' , size : style . markerSize + 2 , fill : style . color } ,
spanGaps : true ,
width : isBanded ? 1.5 : style . width ,
points : { show : false } ,
spanGaps : ! isDigSig ,
... ( pathsFn ? { paths : pathsFn } : { } ) ,
} ) ;
} ) ;
@@ -1228,11 +1421,24 @@ function makeUPlotOpts(p, inTrigMode) {
} ,
{
stroke : '#7f849c' , grid : { stroke : '#313244' , width : 1 } , ticks : { stroke : '#313244' , width : 1 } , size : 60 ,
// Fixed 9 splits at integer divisions [-4..4] matching the normalized Y scale.
splits : ( ) => [ - 4 , - 3 , - 2 , - 1 , 0 , 1 , 2 , 3 , 4 ] ,
// Labels show real-unit values of the active signal for the plot.
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset
splits : ( ) => {
if ( isBanded && p . traces . length > 0 ) {
const n = p . traces . length , bandH = 8 / n ;
return p . traces . map ( ( _ , i ) => 4 - ( i + 0.5 ) * bandH ) ;
}
return [ - 4 , - 3 , - 2 , - 1 , 0 , 1 , 2 , 3 , 4 ] ;
} ,
values : ( u , vals ) => {
if ( isBanded && p . traces . length > 0 ) {
const n = p . traces . length , bandH = 8 / n ;
return vals . map ( v => {
if ( v == null ) return '' ;
const i = Math . round ( ( 4 - v ) / bandH - 0.5 ) ;
if ( i < 0 || i >= n ) return '' ;
const k = p . traces [ i ] ;
return k . includes ( ':' ) ? k . split ( ':' ) . slice ( 1 ) . join ( ':' ) : k ;
} ) ;
}
const activeKey = plotActiveSignal [ p . id ] ;
const vs = activeKey ? sigVScale [ p . id + ':' + activeKey ] : null ;
if ( ! vs ) return vals . map ( v => v == null ? '' : v . toFixed ( 1 ) ) ;
@@ -1246,7 +1452,7 @@ function makeUPlotOpts(p, inTrigMode) {
legend : { show : false } ,
padding : [ 4 , 4 , 0 , 0 ] ,
hooks : {
draw : [ u => { drawActiveSeries ( u , p ) ; drawOffsetMarkers ( u , p ) ; drawCursorLines ( u , p ) ; drawSeriesMarkers ( u , p ) ; drawTriggerMarker ( u , p ) ; } ] ,
draw : [ u => { drawBandSeparators ( u , p ) ; drawActiveSeries ( u , p ) ; drawOffsetMarkers ( u , p ) ; drawCursorLines ( u , p ) ; drawSeriesMarkers ( u , p ) ; drawTriggerMarker ( u , p ) ; } ] ,
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
// All programmatic setScale calls happen without a preceding setSelect, so the
@@ -1529,12 +1735,15 @@ function buildLiveData(p) {
}
// Slice all traces; pick master by sampling rate then count.
// In zoom mode, include one bracketing point on each side so lines are drawn
// across the visible area even when the window contains 0 or 1 samples.
const sliceFn = isRolling ? getBufferSliceRange : getBufferSliceRangeWithBrackets ;
const slices = { } ;
let masterKey = p . traces [ 0 ] , masterCount = - 1 , masterRate = - 1 ;
for ( const key of p . traces ) {
const buf = buffers [ key ] ;
if ( ! buf || buf . size === 0 ) continue ;
const sl = getBufferSliceRange ( buf , t0 , t1 ) ;
const sl = sliceFn ( buf , t0 , t1 ) ;
slices [ key ] = sl ;
const rate = getKeySamplingRate ( key ) ;
if ( rate > masterRate || ( rate === masterRate && sl . t . length > masterCount ) ) {
@@ -1813,10 +2022,56 @@ document.getElementById('btn-cursor').addEventListener('click', () => {
cursorsDirty = true ;
} ) ;
// Format a signal value for the per-plot cursor readout.
function fmtVal ( v ) {
if ( v === null || v === undefined ) return '—' ;
return Math . abs ( v ) >= 10000 ? v . toExponential ( 2 ) : parseFloat ( v . toPrecision ( 4 ) ) . toString ( ) ;
}
// Interpolate the real-unit value of the active/sole signal in plot p at time t.
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 + screenPos
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 ) ;
const sp = vs . screenPos || 0 ;
return ( vNorm - sp ) * dv + ofs ;
}
// Update per-plot cursor value readouts (A, B, ΔV) for all plots.
function updatePlotCursorReadouts ( ) {
plots . forEach ( p => {
const el = document . getElementById ( 'pcur-' + p . id ) ;
if ( ! el ) return ;
// Show only when cursors are on and the plot has an active or sole signal.
const key = plotActiveSignal [ p . id ] || ( p . traces . length === 1 ? p . traces [ 0 ] : null ) ;
if ( cursors . mode !== 'on' || ! key || ! p . uplot ) {
el . style . display = 'none' ;
return ;
}
const vA = getValueAtCursor ( p , cursors . tA ) ;
const vB = getValueAtCursor ( p , cursors . tB ) ;
const dv = ( vA !== null && vB !== null ) ? vB - vA : null ;
document . getElementById ( 'pcur-a-' + p . id ) . textContent = 'A: ' + fmtVal ( vA ) ;
document . getElementById ( 'pcur-b-' + p . id ) . textContent = 'B: ' + fmtVal ( vB ) ;
document . getElementById ( 'pcur-dv-' + p . id ) . textContent = 'ΔV: ' + fmtVal ( dv ) ;
el . style . display = 'flex' ;
} ) ;
}
function updateCursorReadout ( ) {
const ro = document . getElementById ( 'cursor-readout' ) ;
const active = cursors . mode === 'on' ;
ro . classList . toggle ( 'visible' , active ) ;
updatePlotCursorReadouts ( ) ;
if ( ! active ) return ;
// Use the current visible x-range to pick the display unit.
@@ -2046,6 +2301,127 @@ function makeDraggable(key, label, typeName, unit) {
return item ;
}
/* ════════════════════════════════════════════════════════════════
Grid resize handles
════════════════════════════════════════════════════════════════ */
function updateGridTemplate ( ) {
const grid = document . getElementById ( 'plot-grid' ) ;
grid . style . gridTemplateColumns = colFrs . map ( f => f + 'fr' ) . join ( ' ' ) ;
grid . style . gridTemplateRows = rowFrs . map ( f => f + 'fr' ) . join ( ' ' ) ;
// Reposition all handles
document . querySelectorAll ( '.resize-handle-v' ) . forEach ( h => {
const i = parseInt ( h . dataset . col ) ;
const tot = colFrs . reduce ( ( a , b ) => a + b , 0 ) ;
h . style . left = ( colFrs . slice ( 0 , i + 1 ) . reduce ( ( a , b ) => a + b , 0 ) / tot * 100 ) + '%' ;
} ) ;
document . querySelectorAll ( '.resize-handle-h' ) . forEach ( h => {
const i = parseInt ( h . dataset . row ) ;
const tot = rowFrs . reduce ( ( a , b ) => a + b , 0 ) ;
h . style . top = ( rowFrs . slice ( 0 , i + 1 ) . reduce ( ( a , b ) => a + b , 0 ) / tot * 100 ) + '%' ;
} ) ;
// Notify uPlot of new sizes
plots . forEach ( p => { if ( p . uplot ) p . uplot . setSize ( { width : p . div . clientWidth , height : p . div . clientHeight } ) ; } ) ;
}
function setupResizeHandles ( cols , rows ) {
const grid = document . getElementById ( 'plot-grid' ) ;
grid . querySelectorAll ( '.resize-handle-v, .resize-handle-h' ) . forEach ( el => el . remove ( ) ) ;
const makeHandle = ( cls , dataset , onDown ) => {
const h = document . createElement ( 'div' ) ;
h . className = cls ;
Object . assign ( h . dataset , dataset ) ;
h . addEventListener ( 'mousedown' , onDown ) ;
grid . appendChild ( h ) ;
return h ;
} ;
for ( let i = 0 ; i < cols - 1 ; i ++ ) {
const h = makeHandle ( 'resize-handle-v' , { col : i } , e => {
e . preventDefault ( ) ;
const rect = grid . getBoundingClientRect ( ) ;
const tot = colFrs . reduce ( ( a , b ) => a + b , 0 ) ;
const pairSum = colFrs [ i ] + colFrs [ i + 1 ] ;
const leftBefore = colFrs . slice ( 0 , i ) . reduce ( ( a , b ) => a + b , 0 ) ;
h . classList . add ( 'dragging' ) ;
const onMove = ev => {
const pct = ( ev . clientX - rect . left ) / rect . width ;
const newI = Math . max ( 0.1 * pairSum , Math . min ( 0.9 * pairSum , pct * tot - leftBefore ) ) ;
colFrs [ i ] = newI ; colFrs [ i + 1 ] = pairSum - newI ;
updateGridTemplate ( ) ;
} ;
const onUp = ( ) => { h . classList . remove ( 'dragging' ) ; document . removeEventListener ( 'mousemove' , onMove ) ; document . removeEventListener ( 'mouseup' , onUp ) ; } ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ) ;
const tot = colFrs . reduce ( ( a , b ) => a + b , 0 ) ;
h . style . left = ( colFrs . slice ( 0 , i + 1 ) . reduce ( ( a , b ) => a + b , 0 ) / tot * 100 ) + '%' ;
}
for ( let i = 0 ; i < rows - 1 ; i ++ ) {
const h = makeHandle ( 'resize-handle-h' , { row : i } , e => {
e . preventDefault ( ) ;
const rect = grid . getBoundingClientRect ( ) ;
const tot = rowFrs . reduce ( ( a , b ) => a + b , 0 ) ;
const pairSum = rowFrs [ i ] + rowFrs [ i + 1 ] ;
const topBefore = rowFrs . slice ( 0 , i ) . reduce ( ( a , b ) => a + b , 0 ) ;
h . classList . add ( 'dragging' ) ;
const onMove = ev => {
const pct = ( ev . clientY - rect . top ) / rect . height ;
const newI = Math . max ( 0.1 * pairSum , Math . min ( 0.9 * pairSum , pct * tot - topBefore ) ) ;
rowFrs [ i ] = newI ; rowFrs [ i + 1 ] = pairSum - newI ;
updateGridTemplate ( ) ;
} ;
const onUp = ( ) => { h . classList . remove ( 'dragging' ) ; document . removeEventListener ( 'mousemove' , onMove ) ; document . removeEventListener ( 'mouseup' , onUp ) ; } ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ) ;
const tot = rowFrs . reduce ( ( a , b ) => a + b , 0 ) ;
h . style . top = ( rowFrs . slice ( 0 , i + 1 ) . reduce ( ( a , b ) => a + b , 0 ) / tot * 100 ) + '%' ;
}
}
/* ════════════════════════════════════════════════════════════════
Plot config toolbar (click plot title to open)
════════════════════════════════════════════════════════════════ */
let _pcfgOpenId = null ;
function showPlotCfg ( plotId ) {
if ( _pcfgOpenId != null && _pcfgOpenId !== plotId ) hidePlotCfg ( ) ;
_pcfgOpenId = plotId ;
document . getElementById ( 'pcfg-' + plotId ) . style . display = 'block' ;
}
function hidePlotCfg ( ) {
if ( _pcfgOpenId == null ) return ;
const bar = document . getElementById ( 'pcfg-' + _pcfgOpenId ) ;
if ( bar ) bar . style . display = 'none' ;
_pcfgOpenId = null ;
}
function initPlotCfgBar ( plotId , p ) {
const bar = document . getElementById ( 'pcfg-' + plotId ) ;
if ( ! bar ) return ;
const titleInput = bar . querySelector ( '.pcfg-title-input' ) ;
const titleEl = document . getElementById ( 'ptitle-' + plotId ) ;
titleInput . addEventListener ( 'input' , ( ) => {
p . title = titleInput . value || ( 'Plot ' + plotId ) ;
if ( titleEl ) titleEl . textContent = p . title ;
} ) ;
bar . querySelectorAll ( '.pcfg-mode-btn' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
const newMode = btn . dataset . mode ;
if ( p . mode === newMode ) return ;
p . mode = newMode ;
bar . querySelectorAll ( '.pcfg-mode-btn' ) . forEach ( b => b . classList . toggle ( 'active' , b . dataset . mode === newMode ) ) ;
createUPlot ( p ) ;
p . needsRedraw = true ;
} ) ;
} ) ;
bar . querySelector ( '.pcfg-close-btn' ) . addEventListener ( 'click' , hidePlotCfg ) ;
}
/* ════════════════════════════════════════════════════════════════
Layout management
════════════════════════════════════════════════════════════════ */
@@ -2080,7 +2456,18 @@ function applyLayout(cls) {
if ( ! entry ) return ;
const [ label , , cols , rows ] = entry ;
currentLayout = cls ;
document . getElementById ( 'plot-grid' ) . className = cls ;
// Reset fr sizes when layout dimensions change; keep sizes when same layout is re-applied.
if ( cols !== _gridCols || rows !== _gridRows ) {
colFrs = Array ( cols ) . fill ( 1 ) ;
rowFrs = Array ( rows ) . fill ( 1 ) ;
_gridCols = cols ; _gridRows = rows ;
}
const grid = document . getElementById ( 'plot-grid' ) ;
grid . className = cls ; // keeps other grid CSS rules
updateGridTemplate ( ) ; // override template with current fr sizes
setupResizeHandles ( cols , rows ) ;
// Update button label
const btn = document . getElementById ( 'btn-layout' ) ;
@@ -2105,11 +2492,10 @@ function applyLayout(cls) {
while ( plots . length < needed ) addPlot ( ) ;
// Recreate all uPlot instances once the CSS grid has sized the cells.
// This also updates axis visibility (which axes show labels depends on grid position).
requestAnimationFrame ( ( ) => {
plots . forEach ( p => {
createUPlot ( p ) ;
p . needsRedraw = true ; // force immediate data+scale refresh so x/y ranges are preserved
p . needsRedraw = true ;
} ) ;
setTimeout ( ( ) => plots . forEach ( p => {
if ( p . uplot ) p . uplot . setSize ( { width : p . div . clientWidth , height : p . div . clientHeight } ) ;
@@ -2266,8 +2652,28 @@ function addPlot() {
card . className = 'plot-card' ; card . dataset . plotId = id ;
card . innerHTML = `
<div class="plot-card-header">
<div class="plot-title" contenteditable="true" spellcheck="fals e">Plot ${ id } </div >
<span class="plot-title" id="ptitle- ${ id } " title="Click to configur e">Plot ${ id } </span >
<div class="sig-badges" id="badges- ${ id } "></div>
<div class="plot-cursor-ro" id="pcur- ${ id } " style="display:none">
<span class="pcur-a" id="pcur-a- ${ id } ">A: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-b" id="pcur-b- ${ id } ">B: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-dv" id="pcur-dv- ${ id } ">ΔV: —</span>
</div>
</div>
<div class="plot-cfg-bar" id="pcfg- ${ id } " style="display:none">
<div class="vstb-header">
<span class="vstb-label">Title</span>
<input type="text" class="ctx-num pcfg-title-input" value="Plot ${ id } " style="width:90px">
<span class="vstb-label" style="margin-left:6px">Mode</span>
<div class="ctx-btns">
<button class="ctx-btn pcfg-mode-btn active" data-mode="normal">Normal</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="mixed">Mixed</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="digital">Digital</button>
</div>
<button class="vstb-close pcfg-close-btn" title="Close">✕</button>
</div>
</div>
<div class="plot-vscale-bar" id="vstb- ${ id } "></div>
<div class="plot-body" id="pbody- ${ id } ">
@@ -2285,8 +2691,15 @@ 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 , lastDataGen : - 1 } ;
const p = { id , title : 'Plot ' + id , mode : 'normal' , traces : [ ] , div : plotBody , needsRedraw : false , xRange : null , uplot : null , ro : null , lastDataGen : - 1 } ;
plots . push ( p ) ;
// Wire config toolbar
initPlotCfgBar ( id , p ) ;
card . querySelector ( '#ptitle-' + id ) . addEventListener ( 'click' , ( ) => {
if ( _pcfgOpenId === id ) hidePlotCfg ( ) ; else showPlotCfg ( id ) ;
} ) ;
// uPlot creation is handled by applyLayout (batch, after DOM settles).
return id ;
}
@@ -2300,6 +2713,7 @@ function addTraceTo(plotId, signalKey) {
// Recreate uPlot with new series list
createUPlot ( p ) ;
p . needsRedraw = true ;
updatePlotCursorReadouts ( ) ;
}
function removeTraceFrom ( plotId , signalKey ) {
@@ -2315,6 +2729,7 @@ function removeTraceFrom(plotId, signalKey) {
createUPlot ( p ) ;
p . needsRedraw = true ;
if ( ! p . traces . length ) document . querySelector ( '#hint-' + plotId ) . style . display = '' ;
updatePlotCursorReadouts ( ) ;
}
function addBadge ( plotId , key ) {
@@ -2486,6 +2901,9 @@ function renderDirtyPlots() {
zoomGuard = false ;
} ) ;
// Keep per-plot cursor value readouts in sync with live data.
if ( cursors . mode === 'on' ) updatePlotCursorReadouts ( ) ;
requestAnimationFrame ( renderDirtyPlots ) ;
}
@@ -2504,6 +2922,14 @@ document.getElementById('btn-pause-global').addEventListener('click', () => {
document . getElementById ( 'window-select' ) . addEventListener ( 'change' , e => {
windowSec = parseFloat ( e . target . value ) ;
// Grow any buffers that can't hold the full new window for their signal's rate.
Object . keys ( buffers ) . forEach ( key => {
const rate = getKeySamplingRate ( key ) ;
if ( rate > 0 ) {
const needed = Math . min ( MAX _CAP , Math . ceil ( rate * windowSec * 1.5 ) ) ;
if ( needed > buffers [ key ] . cap ) buffers [ key ] = growBuffer ( buffers [ key ] , needed ) ;
}
} ) ;
// Evict rolling-mode LTTB cache — window size change invalidates all cached results.
plots . forEach ( p => lttbCacheEvict ( p . id ) ) ;
// Don't trigger redraws while in trigger mode without a snapshot
@@ -2676,6 +3102,15 @@ function showVScaleMenu(key, plotId) {
document . getElementById ( 'vscale-vdiv' ) . value = dv != null ? parseFloat ( dv . toPrecision ( 4 ) ) : 1 ;
document . getElementById ( 'vscale-pos' ) . value = parseFloat ( ( vs . screenPos || 0 ) . toPrecision ( 4 ) ) ;
// Type row (Analog/Digital) only shown in mixed mode.
const p = plots . find ( q => q . id === plotId ) ;
const isMixed = p && p . mode === 'mixed' ;
document . getElementById ( 'vscale-type-row' ) . style . display = isMixed ? 'flex' : 'none' ;
if ( isMixed ) {
document . querySelectorAll ( '#vscale-type-btns .ctx-btn' ) . forEach ( btn =>
btn . classList . toggle ( 'active' , btn . dataset . type === ( vs . digitalInMixed ? 'digital' : 'analog' ) ) ) ;
}
// Move the toolbar div into this plot's vscale bar.
const bar = document . getElementById ( 'vstb-' + plotId ) ;
if ( bar ) {
@@ -2775,6 +3210,19 @@ function initVScaleMenu() {
vs . screenPos = Math . max ( - 4 , Math . min ( 4 , parseFloat ( e . target . value ) || 0 ) ) ;
refreshPlotForKey ( _vsMenuKey ) ;
} ) ;
// Type buttons (Analog / Digital) — only active in mixed mode.
document . querySelectorAll ( '#vscale-type-btns .ctx-btn' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
if ( ! _vsMenuKey || ! _vsMenuPlotId ) return ;
const vs = getVScale ( _vsMenuPlotId , _vsMenuKey ) ;
vs . digitalInMixed = btn . dataset . type === 'digital' ;
document . querySelectorAll ( '#vscale-type-btns .ctx-btn' ) . forEach ( b =>
b . classList . toggle ( 'active' , b . dataset . type === ( vs . digitalInMixed ? 'digital' : 'analog' ) ) ) ;
// Rebuild needed because series rendering changes (digital = no spanGaps, etc.)
const p = plots . find ( q => q . id === _vsMenuPlotId ) ;
if ( p ) { createUPlot ( p ) ; p . needsRedraw = true ; }
} ) ;
} ) ;
document . getElementById ( 'btn-vscale-close' ) . addEventListener ( 'click' , hideVScaleMenu ) ;
}