'use strict';
/* ════════════════════════════════════════════════════════════════
   Constants
   ════════════════════════════════════════════════════════════════ */
// 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',
];

/* ════════════════════════════════════════════════════════════════
   Globals
   ════════════════════════════════════════════════════════════════ */
// sourcesMap: id → {id, label, addr, state, signals:[]}
const sourcesMap = {};
let buffers = {};
let plots = [];
let nextPlotId = 1;
let windowSec = 5;
let globalPause = false;
let lastDataAt = 0;

const traceColorMap = {};
let colorIdx = 0;
function getTraceColor(key) {
  if (!traceColorMap[key]) traceColorMap[key] = TRACE_COLORS[colorIdx++ % TRACE_COLORS.length];
  return traceColorMap[key];
}

// Per-signal style overrides (color, width, dash, marker, markerSize).
const sigStyle = {};
function getSigStyle(key) {
  if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 };
  return sigStyle[key];
}

// Per-signal vertical scale state: key → {mode, divValue, offset, _resolvedDiv, _resolvedOffset}
const sigVScale = {};
// Active signal per plot: plotId → key
const plotActiveSignal = {};

function setSigStyle(key, updates) {
  const s = getSigStyle(key);
  Object.assign(s, updates);
  if (updates.color) {
    traceColorMap[key] = updates.color;
    // Update badge dots for this key across all plots
    document.querySelectorAll(`.sig-badge[data-key="${CSS.escape(key)}"] .trace-dot`).forEach(dot => {
      dot.style.background = updates.color;
    });
  }
  // Recreate uPlot for all plots containing this key
  plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } });
}

/* ─── VScale helpers ─────────────────────────────────────────────────────── */
// 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, digitalInMixed: false };
  return sigVScale[vsKey];
}

function findSignalMeta(key) {
  const colon = key.indexOf(':');
  if (colon < 0) return null;
  const src = sourcesMap[key.slice(0, colon)];
  if (!src) return null;
  return src.signals.find(s => s.name === key.slice(colon + 1)) || null;
}

// Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array.
// y_norm = (y_raw - offset) / divValue + screenPos
// divValue: units per division  offset: raw value at screen center  screenPos: divisions from center
// Also caches the resolved values in vs._resolvedDiv/_resolvedOffset for Y-axis label use.
function resolveVScale(plotId, key, rawY) {
  const vs = getVScale(plotId, key);
  const screenPos = vs.screenPos || 0;
  if (vs.mode === 'range') {
    const meta = findSignalMeta(key);
    if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
      const divValue = (meta.rangeMax - meta.rangeMin) / 8;
      const offset = (meta.rangeMin + meta.rangeMax) / 2;
      vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
      return { divValue, offset, screenPos };
    }
    // Fall through to auto if no range
  }
  if (vs.mode === 'manual') {
    const divValue = Math.max(vs.divValue || 1, 1e-30);
    const offset = vs.offset != null ? vs.offset : 0;
    vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
    return { divValue, offset, screenPos };
  }
  // Auto: fit data in central 6 of 8 divisions, centered at screenPos
  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; }
  }
  if (!isFinite(min)) { min = -1; max = 1; }
  if (min === max) { min -= 1; max += 1; }
  const divValue = Math.max((max - min) / 6, 1e-30);
  const offset = (max + min) / 2;
  vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
  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);
    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 - offset) / divValue + screenPos;
    }
    return out;
  });
}

// 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) {
    delete plotActiveSignal[plotId];
  } else {
    plotActiveSignal[plotId] = key;
  }
  const c = document.getElementById('badges-' + plotId);
  if (c) c.querySelectorAll('.sig-badge').forEach(b =>
    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.
function refreshPlotForKey(key) {
  plots.forEach(p => {
    if (p.traces.includes(key)) {
      p.needsRedraw = true;
      _updateBadgeVScaleInfo(p.id, key);
    }
  });
}

// Format a numeric value concisely for badge/axis display.
function _fmtVal(v) {
  if (v == null || !isFinite(v)) return '?';
  const abs = Math.abs(v);
  if (abs === 0) return '0';
  if (abs >= 1e4 || abs < 1e-3) return v.toExponential(1);
  return parseFloat(v.toPrecision(3)).toString();
}

// Refresh the vscale info text inside a badge.
function _updateBadgeVScaleInfo(plotId, key) {
  const c = document.getElementById('badges-' + plotId); if (!c) return;
  const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (!b) return;
  const infoEl = b.querySelector('.vscale-info'); if (!infoEl) return;
  const vs = sigVScale[plotId + ':' + key];
  if (!vs) { infoEl.textContent = ''; return; }
  const divValue = vs._resolvedDiv || vs.divValue || 1;
  const sp = vs.screenPos || 0;
  let txt = _fmtVal(divValue) + '/div';
  if (sp !== 0) txt += ' ' + (sp >= 0 ? '+' : '') + sp.toFixed(1) + 'div';
  infoEl.textContent = txt;
}

// Sync: shared uPlot cursor crosshair across all live plots
const LIVE_SYNC = uPlot.sync('live');
const TRIG_SYNC = uPlot.sync('trig');

// Zoom guard: prevents echo on cross-plot sync calls inside onZoom.
// zoomGuard prevents the setScale hook from calling onZoom when we programmatically
// set the scale (rolling window, zoom-back, fit, resize, pan, cross-plot sync).
// All programmatic setScale calls wrap with zoomGuard=true/false so that the hook
// only fires for genuine user drag-zoom or scroll-wheel gestures.
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
const cursors = { mode: 'off', tA: null, tB: null };
let cursorsDirty = false;  // if true, redraw all plots to update cursor lines

// Layout — [label, cssClass, cols, rows]
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],
];
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
   ════════════════════════════════════════════════════════════════ */
const trig = {
  enabled: false, signal: '', edge: 'rising', threshold: 0, windowSec: 1,
  prePercent: 20, mode: 'normal', stopped: false,
  armed: false, collecting: false, trigTime: null, snapshot: null,
};
function trigPreSec() { return trig.windowSec * trig.prePercent / 100; }
function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; }

/* ════════════════════════════════════════════════════════════════
   WebSocket
   ════════════════════════════════════════════════════════════════ */
let ws = null, wsBackoff = 1000;
// Hub address resolution order:
//   1. ?hub=host:port query parameter (explicit override)
//   2. GET /hub served by the static webui server (points at the C++ StreamHub)
//   3. the serving host itself (Go hub mode: it implements /ws directly)
let HUB = new URLSearchParams(location.search).get('hub') || location.host;
async function resolveHub() {
  if (new URLSearchParams(location.search).get('hub')) return;
  try {
    const r = await fetch('/hub', { cache: 'no-store' });
    if (r.ok) {
      const a = (await r.text()).trim();
      if (a) HUB = a;
    }
  } catch { /* no /hub endpoint: Go hub mode, keep location.host */ }
}
function connectWS() {
  ws = new WebSocket('ws://' + HUB + '/ws');
  ws.binaryType = 'arraybuffer';
  ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected – waiting for data'); };
  ws.onclose = () => {
    setStatus('red', 'Disconnected (reconnecting…)');
    setTimeout(connectWS, wsBackoff);
    wsBackoff = Math.min(wsBackoff * 2, 30000);
  };
  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);
    else if (msg.type === 'data') onData(msg);
    else if (msg.type === 'stats') onStats(msg);
    else if (msg.type === 'zoom') onZoomReply(msg);
    else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
    else if (msg.type === 'historyInfo') onHistoryInfo(msg);
    else if (msg.type === 'triggerState') onTriggerState(msg);
  };
}

/* WS zoom request/reply — replaces the Go hub's /api/zoom HTTP endpoint.
   Resolves with the {key:{t,v}} signals map; rejects on timeout/closure. */
let _zoomReqId = 0;
const _zoomPending = new Map();
function wsZoomRequest(t0, t1, n, keys) {
  return new Promise((resolve, reject) => {
    if (!ws || ws.readyState !== WebSocket.OPEN) { reject(new Error('ws not open')); return; }
    const reqId = ++_zoomReqId;
    const timer = setTimeout(() => {
      _zoomPending.delete(reqId);
      reject(new Error('zoom request timeout'));
    }, 5000);
    _zoomPending.set(reqId, { resolve, timer });
    ws.send(JSON.stringify({ type: 'zoom', reqId, t0, t1, n, signals: keys.join(',') }));
  });
}
function onZoomReply(msg) {
  const p = _zoomPending.get(msg.reqId);
  if (!p) return;
  clearTimeout(p.timer);
  _zoomPending.delete(msg.reqId);
  p.resolve(msg.signals || {});
}

/* ════════════════════════════════════════════════════════════════
   History (disk-backed data from the hub)
   ════════════════════════════════════════════════════════════════ */
let historyMeta = null;  // {enabled, durationHours, decimation, signals:{key:{t0,t1,count,capacity}}}
const historyData = {};  // plotId → {signals:{key:{t,v}}, t0, t1}

const _histPending = new Map();
function wsHistoryZoomRequest(t0, t1, n, keys) {
  return new Promise((resolve, reject) => {
    if (!ws || ws.readyState !== WebSocket.OPEN) { reject(new Error('ws not open')); return; }
    const reqId = ++_zoomReqId;
    const timer = setTimeout(() => {
      _histPending.delete(reqId);
      reject(new Error('historyZoom timeout'));
    }, 10000);
    _histPending.set(reqId, { resolve, timer });
    ws.send(JSON.stringify({ type: 'historyZoom', reqId, t0, t1, n, signals: keys.join(',') }));
  });
}

function onHistoryZoomReply(msg) {
  const p = _histPending.get(msg.reqId);
  if (!p) return;
  clearTimeout(p.timer);
  _histPending.delete(msg.reqId);
  p.resolve(msg.signals || {});
}

function onHistoryInfo(msg) {
  historyMeta = {
    enabled: msg.enabled || false,
    durationHours: msg.durationHours || 0,
    decimation: msg.decimation || 1,
    signals: msg.signals || {},
  };
  updateHistoryUI();
}

function updateHistoryUI() {
  const badge = document.getElementById('history-badge');
  if (!badge) return;
  if (historyMeta && historyMeta.enabled) {
    const nSigs = Object.keys(historyMeta.signals).length;
    badge.textContent = `History: ${historyMeta.durationHours}h, ${nSigs} signals`;
    badge.style.display = '';
  } else {
    badge.style.display = 'none';
  }
}

/* ════════════════════════════════════════════════════════════════
   Status LED
   ════════════════════════════════════════════════════════════════ */
function setStatus(s, t) {
  document.getElementById('status-led').className = s;
  document.getElementById('status-text').textContent = t;
}
setInterval(() => {
  const tsEl = document.getElementById('sb-tsage');
  if (ws && ws.readyState === WebSocket.OPEN && lastDataAt > 0) {
    const age = performance.now() - lastDataAt;
    // Compute minimum lag: newest buffer timestamp vs browser wall clock.
    let tsAge = null;
    const wallNow = Date.now() / 1000;
    Object.values(buffers).forEach(buf => {
      if (buf.size === 0) return;
      const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
      const a = wallNow - newest;
      if (tsAge === null || a < tsAge) tsAge = a;
    });
    if (tsEl && tsAge !== null) {
      const ms = tsAge * 1000;
      tsEl.textContent = '| lag: ' + (ms < 1000 ? ms.toFixed(0) + 'ms' : tsAge.toFixed(2) + 's');
    } else if (tsEl) {
      tsEl.textContent = '';
    }
    if (age > 1000) setStatus('orange', 'No data for ' + (age / 1000).toFixed(1) + 's');
    else setStatus('green', 'Streaming');
  } else if (tsEl) {
    tsEl.textContent = '';
  }
}, 500);

/* ════════════════════════════════════════════════════════════════
   Config handler
   ════════════════════════════════════════════════════════════════ */
function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); }
function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; }

function onConfig(msg) {
  const sid = msg.sourceId;
  if (!sid) return;
  // Ensure source exists (may arrive before 'sources' message in some edge cases).
  if (!sourcesMap[sid]) {
    sourcesMap[sid] = { id: sid, label: sid, addr: '', state: 'connected', signals: [] };
  }
  const src = sourcesMap[sid];
  const newSigs = msg.signals || [];
  const oldSigs = src.signals || [];
  const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0);
  const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i]));
  src.signals = newSigs;
  if (changed) {
    // Remove old buffers for this source only (prefix: "sid:").
    const prefix = sid + ':';
    Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
    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(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;
    }
    zoomHistory.length = 0;
    document.getElementById('btn-zoom-back').style.display = 'none';
  }
  buildSidebar();
  buildTrigSignalSelect();
}

/* ════════════════════════════════════════════════════════════════
   Data handler
   ════════════════════════════════════════════════════════════════ */
function onData(msg) {
  lastDataAt = performance.now();
  const sigs = msg.signals; if (!sigs) return;
  Object.keys(sigs).forEach(key => {
    const buf = buffers[key]; if (!buf) return;
    const sd = sigs[key]; if (!sd || !sd.t || !sd.v) return;
    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) {
    plots.forEach(p => {
      if (globalPause) return;
      if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true;
    });
  }
}

/* ════════════════════════════════════════════════════════════════
   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;
  const version = dv.getUint8(off); off += 1;
  if (version === 2) { onTriggerCapture(dv, buf, off); return; }
  if (version !== 1) return;

  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;

  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;
    }
    // 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;
    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
  }

  if (!trig.enabled) {
    _dataGen++;
    plots.forEach(p => {
      if (globalPause) return;
      if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true;
    });
  }
}

/* Trigger logic runs hub-side (C++ StreamHub TriggerEngine).  The SPA sends
   the configuration + arm/disarm commands, tracks the FSM via "triggerState"
   broadcasts, and receives the finished capture as a version-2 binary frame. */
function wsSend(obj) {
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function sendTrigConfig() {
  wsSend({
    type: 'setTrigger', signal: trig.signal, edge: trig.edge,
    threshold: trig.threshold, windowSec: trig.windowSec,
    prePercent: trig.prePercent, mode: trig.mode,
  });
}

// Hub FSM broadcast: {state:"idle|armed|collecting|triggered", mode, stopped[, trigTime]}
function onTriggerState(msg) {
  const st = msg.state || 'idle';
  trig.stopped = !!msg.stopped;
  updateStopBtn();
  if (st === 'armed') {
    trig.armed = true; trig.collecting = false;
    setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('armed');
  } else if (st === 'collecting') {
    trig.armed = false; trig.collecting = true;
    if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime;
    // Clear the old snapshot now (mirrors the old fireTrigger) so the new
    // capture replaces it when the v2 frame arrives.
    trig.snapshot = null;
    updateTrigStatusBadge('waiting'); setAllCardsCollecting(true);
  } else if (st === 'triggered') {
    trig.armed = false; trig.collecting = false;
    if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime;
    setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
    showRearmBtn(trig.mode === 'single');
    showStopBtn(trig.mode === 'normal');
  } else { // idle
    trig.armed = false; trig.collecting = false;
    setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('idle');
  }
}

// Version-2 binary capture frame from the hub:
// [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig]
// {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]}
function onTriggerCapture(dv, buf, off) {
  const trigTime = dv.getFloat64(off, true); off += 8;
  const preS = dv.getFloat64(off, true); off += 8;
  const postS = dv.getFloat64(off, true); off += 8;
  const nSig = dv.getUint32(off, true); off += 4;

  const snap = {};
  for (let s = 0; s < nSig; s++) {
    const keyLen = dv.getUint16(off, true); off += 2;
    const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen));
    off += keyLen;
    const n = dv.getUint32(off, true); off += 4;
    // slice() copies — guarantees the 8-byte alignment Float64Array requires.
    const t = new Float64Array(buf.slice(off, off + n * 8)); off += n * 8;
    const v = new Float64Array(buf.slice(off, off + n * 8)); off += n * 8;
    snap[key] = { t, v };
  }
  // Window parameters latched at fire time (hub-side) so later UI edits do
  // not affect how this capture is rendered.
  snap._preS = preS;
  snap._postS = postS;

  trig.trigTime = trigTime;
  trig.snapshot = snap;
  trig.collecting = false;
  setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
  showRearmBtn(trig.mode === 'single');
  showStopBtn(trig.mode === 'normal');
  // Show cursor button now that snapshot exists (positions preserved from before)
  updateCursorBtnVisibility();
  plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
}

function trigArm() {
  // Do NOT clear trig.snapshot here — keep the last waveform and trigger marker
  // visible while waiting for the next event.  The "collecting" triggerState
  // broadcast clears it when a new trigger actually fires.
  trig.armed = true; trig.collecting = false;
  showRearmBtn(false); updateTrigStatusBadge('armed');
  updateCursorBtnVisibility();
  plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
  sendTrigConfig();
  wsSend({ type: 'arm' });
}
function trigDisarm() {
  trig.armed = false; trig.collecting = false; trig.trigTime = null; trig.stopped = false;
  setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle');
  wsSend({ type: 'disarm' });
}
function setAllCardsCollecting(on) {
  document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on));
}
function updateTrigStatusBadge(state) {
  const el = document.getElementById('trig-status-badge');
  el.className = state;
  el.textContent = { idle: 'IDLE', armed: 'ARMED', waiting: 'COLLECTING', triggered: 'TRIGGERED' }[state] || 'IDLE';
}
function showRearmBtn(v) { document.getElementById('btn-trig-rearm').style.display = v ? 'inline-block' : 'none'; }
function showStopBtn(v) { document.getElementById('btn-trig-stop').style.display = v ? 'inline-block' : 'none'; }
function updateStopBtn() {
  const btn = document.getElementById('btn-trig-stop');
  btn.textContent = trig.stopped ? 'Resume' : 'Stop';
}

/* ════════════════════════════════════════════════════════════════
   Circular buffer
   ════════════════════════════════════════════════════════════════ */
function makeBuffer(cap) {
  cap = cap || DEFAULT_CAP;
  return { t: new Float64Array(cap), v: new Float64Array(cap), head: 0, size: 0, cap };
}
function pushBuffer(buf, t, v) {
  buf.t[buf.head] = t; buf.v[buf.head] = v;
  buf.head = (buf.head + 1) % buf.cap;
  if (buf.size < buf.cap) buf.size++;
}

// Binary-search range slice of circular buffer — O(log n + window_size)
function getBufferSliceRange(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, len = kEnd - kStart;
  if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) };

  const outT = new Float64Array(len), outV = new Float64Array(len);
  const physStart = physAt(kStart), tail = cap - physStart;
  if (tail >= len) {
    outT.set(buf.t.subarray(physStart, physStart + len));
    outV.set(buf.v.subarray(physStart, physStart + len));
  } else {
    outT.set(buf.t.subarray(physStart, physStart + tail));
    outT.set(buf.t.subarray(0, len - tail), tail);
    outV.set(buf.v.subarray(physStart, physStart + tail));
    outV.set(buf.v.subarray(0, len - tail), tail);
  }
  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
// and the browser.  Falls back to Date.now()/1000 only when all buffers are
// empty (no data yet received).
function getGlobalNow() {
  let latest = -Infinity;
  Object.values(buffers).forEach(buf => {
    if (buf.size === 0) return;
    const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
    if (newestT > latest) latest = newestT;
  });
  return isFinite(latest) ? latest : Date.now() / 1000;
}

// getBufferNow returns the "now" anchor for a single buffer — the buffer's own
// newest timestamp.  This avoids cross-signal interference when signals have
// different timescales or update rates.
function getBufferNow(buf) {
  if (buf.size === 0) return Date.now() / 1000;
  return buf.t[(buf.head - 1 + buf.cap) % buf.cap];
}

function getBufferSlice(buf) {
  const now = getBufferNow(buf);
  return getBufferSliceRange(buf, now - windowSec, now);
}

// Binary-search slice of a sorted contiguous Float64Array pair
function sliceTypedArrayRange(t, v, t0, t1) {
  let lo = 0, hi = t.length;
  while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] < t0) lo = m + 1; else hi = m; }
  const s = lo; lo = s; hi = t.length;
  while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] <= t1) lo = m + 1; else hi = m; }
  return { t: t.subarray(s, lo), v: v.subarray(s, lo) };
}


// Return the configured samplingRate for a buffer key.
// Temporal array signals have a meaningful SamplingRate; scalars return 0.
// Used to prefer high-freq signals as the master time grid regardless of trace order.
function getKeySamplingRate(key) {
  // key format: "sourceId:signalName" or "sourceId:signalName[i]"
  for (const src of Object.values(sourcesMap)) {
    const prefix = src.id + ':';
    if (!key.startsWith(prefix)) continue;
    const localKey = key.slice(prefix.length);
    const direct = (src.signals || []).find(s => s.name === localKey);
    if (direct) return direct.samplingRate || 0;
    const sig = (src.signals || []).find(s => localKey.startsWith(s.name + '['));
    if (sig) return sig.samplingRate || 0;
  }
  return 0;
}

// Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default).
function makeSeriesPath(key) {
  const style = getSigStyle(key);
  if (style.dash === 'solid') return null;
  const dashPat = style.dash === 'dashed' ? [6, 4] : [2, 3];
  return (u, si) => {
    const xd = u.data[0], yd = u.data[si];
    if (!xd || !yd || !u.bbox) return { stroke: null, fill: null };
    const { ctx, bbox } = u;
    ctx.save();
    ctx.beginPath();
    ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
    ctx.clip();
    ctx.strokeStyle = style.color;
    ctx.lineWidth = style.width;
    ctx.setLineDash(dashPat);
    ctx.lineJoin = 'round';
    ctx.beginPath();
    let moved = false;
    for (let i = 0; i < xd.length; i++) {
      if (yd[i] == null) { moved = false; continue; }
      const cx = u.valToPos(xd[i], 'x', true);
      const cy = u.valToPos(yd[i], 'y', true);
      if (!moved) { ctx.moveTo(cx, cy); moved = true; }
      else ctx.lineTo(cx, cy);
    }
    ctx.stroke();
    ctx.restore();
    return { stroke: null, fill: null }; // tell uPlot not to draw anything on top
  };
}

/* ════════════════════════════════════════════════════════════════
   LTTB Web Worker — offloads decimation off the main thread.
   Cache key: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>"
   On cache-hit  → render uses cached {t, v} immediately (stale-while-revalidate).
   On cache-miss → render falls back to sync lttb once (first zoom render only),
                   then worker takes over for subsequent updates.
   ════════════════════════════════════════════════════════════════ */
const lttbCache = new Map();   // key → {t, v}
const lttbPending = new Set(); // keys currently in-flight

let _lttbWorker = null;
try {
  _lttbWorker = new Worker('lttb-worker.js');
  _lttbWorker.onmessage = function({ data: { id, t, v } }) {
    lttbPending.delete(id);
    lttbCache.set(id, { t, v });
    // Invalidate and redraw the owning plot.
    const plotId = parseInt(id.split(':')[0], 10);
    const p = plots.find(q => q.id === plotId);
    if (p) { p.needsRedraw = true; }
  };
  _lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e);
} catch(e) {
  console.warn('[lttb-worker] unavailable, using sync fallback:', e);
}

// Submit a LTTB job to the worker (or run sync if worker unavailable).
// Returns cached {t, v} if fresh, null if a worker job was just submitted,
// or a sync result if the worker is unavailable.
function lttbAsync(cacheKey, t, v, threshold) {
  const cached = lttbCache.get(cacheKey);
  if (cached) return cached;                 // cache hit — use immediately

  if (!lttbPending.has(cacheKey)) {
    lttbPending.add(cacheKey);
    if (_lttbWorker) {
      // Send copies so the main thread retains the originals.
      const tCopy = new Float64Array(t);
      const vCopy = new Float64Array(v);
      _lttbWorker.postMessage({ id: cacheKey, t: tCopy, v: vCopy, threshold },
                              [tCopy.buffer, vCopy.buffer]);
    } else {
      // Synchronous fallback (worker unavailable).
      const result = lttb(t, v, threshold);
      lttbCache.set(cacheKey, result);
      lttbPending.delete(cacheKey);
      return result;
    }
  }
  return null;  // worker job in-flight — caller should use fallback
}

// Evict stale LTTB cache entries for a plot (call when zoom range changes).
function lttbCacheEvict(plotId) {
  const prefix = plotId + ':';
  for (const k of [...lttbCache.keys()]) {
    if (k.startsWith(prefix)) lttbCache.delete(k);
  }
  for (const k of [...lttbPending]) {
    if (k.startsWith(prefix)) lttbPending.delete(k);
  }
}

// LTTB decimation — O(n).  Returns {t, v} unchanged when len ≤ threshold.
function lttb(t, v, threshold) {
  const len = t.length;
  if (len <= threshold) return { t, v };
  const outT = new Float64Array(threshold), outV = new Float64Array(threshold);
  outT[0] = t[0]; outV[0] = v[0];
  outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1];
  const every = (len - 2) / (threshold - 2);
  let a = 0;
  for (let i = 0; i < threshold - 2; i++) {
    const avgS = Math.floor((i + 1) * every) + 1, avgE = Math.min(Math.floor((i + 2) * every) + 1, len);
    let avgT = 0, avgV = 0, n = 0;
    for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
    if (n) { avgT /= n; avgV /= n; }
    const rS = Math.floor(i * every) + 1, rE = Math.min(Math.floor((i + 1) * every) + 1, len);
    let maxA = -1, next = rS;
    const aT = t[a], aV = v[a];
    for (let j = rS; j < rE; j++) {
      const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV));
      if (area > maxA) { maxA = area; next = j; }
    }
    outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next;
  }
  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);

  // Fire regular zoom + history zoom in parallel
  const promises = [];

  // Regular zoom (in-memory ring)
  promises.push(wsZoomRequest(t0, t1, targetPts, [...allKeys]).catch(e => {
    console.warn('zoom fetch:', e); return null;
  }));

  // History zoom (disk) — only if history is enabled
  if (historyMeta && historyMeta.enabled) {
    promises.push(wsHistoryZoomRequest(t0, t1, targetPts, [...allKeys]).catch(e => {
      console.warn('history zoom fetch:', e); return null;
    }));
  }

  const [sigs, histSigs] = await Promise.all(promises);

  // Merge: history data first (older), regular zoom overlays (newer wins)
  const merged = {};
  if (histSigs) {
    Object.entries(histSigs).forEach(([k, sd]) => {
      if (!sd || !sd.t || !sd.v) return;
      merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
    });
  }
  if (sigs) {
    Object.entries(sigs).forEach(([k, sd]) => {
      if (!sd || !sd.t || !sd.v || sd.t.length === 0) return;
      const zt = Float64Array.from(sd.t), zv = Float64Array.from(sd.v);
      if (merged[k] && merged[k].t.length > 0) {
        // Merge: use history for t < ring oldest, ring for the rest
        const ht = merged[k].t, hv = merged[k].v;
        const ringOldest = zt[0];
        // Find cutoff in history data
        let cutIdx = ht.length;
        for (let i = 0; i < ht.length; i++) {
          if (ht[i] >= ringOldest) { cutIdx = i; break; }
        }
        if (cutIdx > 0) {
          // Prepend history data before ring data
          const mt = new Float64Array(cutIdx + zt.length);
          const mv = new Float64Array(cutIdx + zv.length);
          mt.set(ht.subarray(0, cutIdx));
          mv.set(hv.subarray(0, cutIdx));
          mt.set(zt, cutIdx);
          mv.set(zv, cutIdx);
          merged[k] = { t: mt, v: mv };
        } else {
          merged[k] = { t: zt, v: zv };
        }
      } else {
        merged[k] = { t: zt, v: zv };
      }
    });
  }

  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.
// 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) {
    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;
    }
  }

  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))];

  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; }
    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));
  }
  return [sharedT, ...applyVScaleNorm(p, yArrays)];
}

/* ════════════════════════════════════════════════════════════════
   uPlot helpers
   ════════════════════════════════════════════════════════════════ */
// ─── Time formatting helpers ──────────────────────────────────────────────────

// Returns the span (in seconds) of the currently visible x-axis across all plots.
// Falls back to windowSec when no uPlot instances exist yet.
function currentXSpan() {
  for (const p of plots) {
    if (p.uplot) {
      const s = p.uplot.scales.x;
      if (s && s.min != null && s.max != null) return Math.abs(s.max - s.min);
    }
  }
  return windowSec;
}

// Format a signed duration (seconds) auto-selecting s / ms / µs / ns based on
// refSpan (e.g. the visible x-range or the value itself).
// sign = '+' prefix only when showSign is true (default false for ΔT display).
function fmtDuration(sec, refSpan, showSign) {
  const abs = Math.abs(sec);
  const sign = showSign ? (sec < 0 ? '−' : '+') : (sec < 0 ? '−' : '');
  if (refSpan < 1e-6) {        // nanosecond range
    return sign + (abs * 1e9).toFixed(1) + ' ns';
  } else if (refSpan < 1e-3) { // microsecond range
    return sign + (abs * 1e6).toFixed(3) + ' µs';
  } else if (refSpan < 1) {    // millisecond range
    return sign + (abs * 1e3).toFixed(3) + ' ms';
  } else {                      // second range
    return sign + abs.toFixed(6) + ' s';
  }
}

// Format a Unix-seconds timestamp → HH:MM:SS.fraction
// The number of sub-second digits adapts to the visible x-range span.
function fmtLiveTime(v, span) {
  const d = new Date(v * 1000);
  const hh = String(d.getHours()).padStart(2, '0');
  const mm = String(d.getMinutes()).padStart(2, '0');
  const ss = String(d.getSeconds()).padStart(2, '0');
  const frac = v - Math.floor(v);             // sub-second part, full float64 precision
  if (span < 1e-6) {
    // show 9 decimal places (ns precision)
    const ns = Math.round(frac * 1e9);
    return hh + ':' + mm + ':' + ss + '.' + String(ns).padStart(9, '0');
  } else if (span < 1e-3) {
    // show 6 decimal places (µs precision)
    const us = Math.round(frac * 1e6);
    return hh + ':' + mm + ':' + ss + '.' + String(us).padStart(6, '0');
  } else if (span < 1) {
    // show 3 decimal places (ms precision) — tick labels only show ms
    const ms = String(d.getMilliseconds()).padStart(3, '0');
    return hh + ':' + mm + ':' + ss + '.' + ms;
  } else {
    const ms = String(d.getMilliseconds()).padStart(3, '0');
    return hh + ':' + mm + ':' + ss + '.' + ms;
  }
}

// Format Unix seconds → HH:MM:SS.fraction  (used for live x-axis ticks)
// Precision adapts to the visible x-range (via u.scales.x.{min,max}).
function fmtLiveTick(u, vals) {
  const span = (u.scales.x && u.scales.x.max != null)
    ? Math.abs(u.scales.x.max - u.scales.x.min) : windowSec;
  return vals.map(v => v == null ? '' : fmtLiveTime(v, span));
}

// Format relative seconds → auto-scaled unit  (used for trigger x-axis ticks)
// Unit (s/ms/µs/ns) is determined by the visible x-range span.
function fmtTrigTick(u, vals) {
  const span = (u.scales.x && u.scales.x.max != null)
    ? Math.abs(u.scales.x.max - u.scales.x.min) : 1;
  return vals.map(v => v == null ? '' : fmtDuration(v, span, true));
}

// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold
// line on any plot that shows the trigger signal.
function drawTriggerMarker(u, p) {
  if (!trig.enabled || !trig.snapshot) return;
  const { ctx, bbox } = u;
  if (!bbox) return;
  const x = u.valToPos(0, 'x', true);
  if (x < bbox.left || x > bbox.left + bbox.width) return;
  const px = Math.round(x);
  ctx.save();
  // Thin dashed vertical line at t=0
  ctx.strokeStyle = 'rgba(203,166,247,0.7)';
  ctx.lineWidth = 1;
  ctx.setLineDash([4, 3]);
  ctx.beginPath();
  ctx.moveTo(px, bbox.top);
  ctx.lineTo(px, bbox.top + bbox.height);
  ctx.stroke();
  ctx.setLineDash([]);
  ctx.fillStyle = 'rgba(203,166,247,0.7)';
  ctx.font = 'bold 9px monospace';
  ctx.textBaseline = 'top';
  ctx.fillText('T', px + 3, bbox.top + 2);
  // Horizontal threshold line — only on plots that contain the trigger signal
  if (p && trig.signal && p.traces.includes(trig.signal)) {
    // Normalize the raw threshold to this plot's vscale for the trigger signal.
    const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null;
    let threshNorm = trig.threshold;
    if (tvs) {
      const dv = tvs._resolvedDiv || tvs.divValue || 1;
      const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0);
      const sp = tvs.screenPos || 0;
      threshNorm = (trig.threshold - ofs) / dv + sp;
    }
    const y = u.valToPos(threshNorm, 'y', true);
    if (y >= bbox.top && y <= bbox.top + bbox.height) {
      const py = Math.round(y);
      ctx.strokeStyle = 'rgba(203,166,247,0.45)';
      ctx.lineWidth = 0.75;
      ctx.setLineDash([3, 4]);
      ctx.beginPath();
      ctx.moveTo(bbox.left, py);
      ctx.lineTo(bbox.left + bbox.width, py);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = 'rgba(203,166,247,0.45)';
      ctx.font = '9px monospace';
      ctx.textBaseline = 'bottom';
      ctx.fillText(trig.threshold.toPrecision(4), bbox.left + 4, py - 1);
    }
  }
  ctx.restore();
}

// Linearly interpolate series value at time t from uPlot's current rendered data.
function interpAtTime(u, si, t) {
  const td = u.data[0], vd = u.data[si];
  if (!td || td.length === 0 || !vd) return null;
  let lo = 0, hi = td.length - 1;
  while (lo < hi) { const m = (lo + hi) >> 1; if (td[m] < t) lo = m + 1; else hi = m; }
  if (lo === 0) return vd[0] ?? null;
  const t0 = td[lo - 1], t1 = td[lo];
  const v0 = vd[lo - 1], v1 = vd[lo];
  if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
  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 || p.mode === 'digital' || p.mode === 'mixed') return;
  const activeKey = plotActiveSignal[p.id];
  if (!activeKey) return;
  const idx = p.traces.indexOf(activeKey);
  if (idx < 0) return;
  const xs = u.data[0];
  const ys = u.data[idx + 1]; // +1 because index 0 is time
  if (!xs || !ys) return;
  const style = getSigStyle(activeKey);
  const dpr = window.devicePixelRatio || 1;
  const { ctx } = u;
  ctx.save();
  ctx.strokeStyle = style.color;
  ctx.lineWidth = style.width * 2 * dpr;
  ctx.lineJoin = 'round';
  ctx.lineCap = 'round';
  ctx.beginPath();
  let started = false;
  for (let i = 0; i < xs.length; i++) {
    const yv = ys[i];
    if (yv == null || !isFinite(yv)) { started = false; continue; }
    const xPx = u.valToPos(xs[i], 'x', true);
    const yPx = u.valToPos(yv, 'y', true);
    if (!started) { ctx.moveTo(xPx, yPx); started = true; }
    else ctx.lineTo(xPx, yPx);
  }
  ctx.stroke();
  ctx.restore();
}

// 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 || p.mode === 'digital' || p.mode === 'mixed') return;
  const { ctx, bbox } = u;
  const dpr = window.devicePixelRatio || 1;

  p.traces.forEach(key => {
    const vs = sigVScale[p.id + ':' + key];
    const screenPos = vs ? (vs.screenPos || 0) : 0;
    const yCtr = u.valToPos(screenPos, 'y', true);
    const mH = (plotActiveSignal[p.id] === key ? 7 : 5) * dpr;
    const mW = (plotActiveSignal[p.id] === key ? 10 : 7) * dpr;
    if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return;
    const isActive = plotActiveSignal[p.id] === key;
    ctx.save();
    ctx.fillStyle = getSigStyle(key).color;
    // Right-pointing triangle: tip at left edge of plot area, body extends left into Y-axis area
    ctx.beginPath();
    ctx.moveTo(bbox.left + dpr, yCtr);
    ctx.lineTo(bbox.left - mW + dpr, yCtr - mH);
    ctx.lineTo(bbox.left - mW + dpr, yCtr + mH);
    ctx.closePath();
    ctx.fill();
    if (isActive) {
      ctx.strokeStyle = 'rgba(255,255,255,0.75)';
      ctx.lineWidth = dpr;
      ctx.stroke();
    }
    ctx.restore();
  });
}

// Draw custom marker shapes (square, cross, diamond) for all series in a plot.
// Circle markers are handled natively by uPlot's points option.
function drawSeriesMarkers(u, p) {
  if (!u.bbox) return;
  const { ctx, bbox } = u;
  ctx.save();
  ctx.beginPath();
  ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
  ctx.clip();
  p.traces.forEach((key, idx) => {
    const style = getSigStyle(key);
    if (style.marker === 'none' || style.marker === 'circle') return;
    const si = idx + 1;
    const xd = u.data[0], yd = u.data[si];
    if (!xd || !yd) return;
    const sz = style.markerSize;
    ctx.strokeStyle = style.color;
    ctx.fillStyle = style.color;
    ctx.lineWidth = 1.5;
    ctx.setLineDash([]);
    for (let i = 0; i < xd.length; i++) {
      if (yd[i] == null) continue;
      const cx = u.valToPos(xd[i], 'x', true);
      const cy = u.valToPos(yd[i], 'y', true);
      if (cx < bbox.left || cx > bbox.left + bbox.width ||
        cy < bbox.top || cy > bbox.top + bbox.height) continue;
      ctx.beginPath();
      if (style.marker === 'square') {
        ctx.rect(cx - sz / 2, cy - sz / 2, sz, sz); ctx.fill();
      } else if (style.marker === 'cross') {
        ctx.moveTo(cx - sz / 2, cy); ctx.lineTo(cx + sz / 2, cy);
        ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx, cy + sz / 2);
        ctx.stroke();
      } else if (style.marker === 'diamond') {
        ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx + sz / 2, cy);
        ctx.lineTo(cx, cy + sz / 2); ctx.lineTo(cx - sz / 2, cy);
        ctx.closePath(); ctx.fill();
      }
    }
  });
  ctx.restore();
}

// Draw cursor A/B vertical lines and signal value labels (called from draw hook).
function drawCursorLines(u, p) {
  if (cursors.mode !== 'on') return;
  const { ctx, bbox } = u;
  if (!bbox) return;

  const drawLine = (val, color, label) => {
    if (val === null) return;
    const x = u.valToPos(val, 'x', true);
    if (x < bbox.left || x > bbox.left + bbox.width) return;
    const px = Math.round(x);
    ctx.save();
    // Clip to plot area
    ctx.beginPath();
    ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
    ctx.clip();
    // Vertical dashed cursor line
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.5;
    ctx.setLineDash([5, 4]);
    ctx.beginPath();
    ctx.moveTo(px, bbox.top);
    ctx.lineTo(px, bbox.top + bbox.height);
    ctx.stroke();
    ctx.setLineDash([]);
    // Cursor label at top
    ctx.fillStyle = color;
    ctx.font = 'bold 12px monospace';
    ctx.textBaseline = 'top';
    ctx.fillText(label, px + 14, bbox.top + 2);
    // Per-trace: diamond at crossing point + value label
    if (p) {
      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);
        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 + screenPos  →  y_raw = (y_norm - screenPos) * divValue + offset
        const vs = sigVScale[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);
          const sp = vs.screenPos || 0;
          vReal = (vNorm - sp) * dv + ofs;
        }
        const tc = getSigStyle(key).color;
        // Diamond marker at intersection
        ctx.fillStyle = tc;
        ctx.strokeStyle = tc;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(px, cy - DSZ);
        ctx.lineTo(px + DSZ, cy);
        ctx.lineTo(px, cy + DSZ);
        ctx.lineTo(px - DSZ, cy);
        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();
        ctx.fillStyle = tc;
        ctx.font = '11px monospace';
        const currentAlign = ctx.textAlign;
        ctx.textAlign = 'left';
        ctx.textBaseline = 'middle';
        ctx.fillText(str, px + DSZ + 4, cy);
        ctx.textAlign = currentAlign;
      });
    }
    ctx.restore();
  };

  drawLine(cursors.tA, 'rgba(137,220,235,0.85)', 'A');
  drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B');
}

// Compute the rolling-window anchor ("newest common timestamp") for a plot.
// 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 => {
    const colon = key.indexOf(':');
    if (colon < 0) return;
    const srcId = key.slice(0, colon);
    const buf = buffers[key];
    if (!buf || buf.size === 0) return;
    const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
    if (sourceNewest[srcId] === undefined || t > sourceNewest[srcId]) sourceNewest[srcId] = t;
  });
  const srcVals = Object.values(sourceNewest);
  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;
}

// 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 = 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: isBanded ? 1.5 : style.width,
      points: { show: false },
      spanGaps: !isDigSig,
      ...(pathsFn ? { paths: pathsFn } : {}),
    });
  });

  const xVals = inTrigMode ? (u, vals) => fmtTrigTick(u, vals)
    : (u, vals) => fmtLiveTick(u, vals);

  return {
    width: Math.max(p.div.clientWidth || 100, 50),
    height: Math.max(p.div.clientHeight || 100, 50),
    cursor: {
      sync: { key: inTrigMode ? 'trig' : 'live', setSeries: false },
      drag: { x: true, y: false, setScale: true, uni: 20 },
      lock: false,
    },
    select: { show: true },
    scales: {
      x: (() => {
        let xMin, xMax;
        if (p.xRange) {
          xMin = p.xRange[0]; xMax = p.xRange[1];
        } else {
          const now = computePlotNow(p);
          xMin = now - windowSec; xMax = now;
        }
        return { time: false, auto: false, min: xMin, max: xMax };
      })(),
      y: { auto: false, min: -4.5, max: 4.5 },
    },
    series: seriesArr,
    axes: [
      {
        stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 },
        values: xVals, size: 36,
        // Always produce exactly 10 horizontal divisions (11 evenly-spaced tick lines).
        splits: (u, _ai, sMin, sMax) => {
          const n = 10, span = sMax - sMin;
          if (span === 0) return [sMin];
          return Array.from({ length: n + 1 }, (_, i) => sMin + span * i / n);
        },
      },
      {
        stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 60,
        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));
          const divValue = vs._resolvedDiv || vs.divValue || 1;
          const offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
          const screenPos = vs.screenPos || 0;
          return vals.map(v => v == null ? '' : _fmtVal((v - screenPos) * divValue + offset));
        },
      },
    ],
    legend: { show: false },
    padding: [4, 4, 0, 0],
    hooks: {
      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
      // flag is false and onZoom is never called unintentionally.
      setSelect: [(u) => { u._userZoom = (u.select.width > 0); }],
      setScale: [(u, key) => {
        if (key !== 'x' || !u._userZoom || !u._ready) return;
        u._userZoom = false;
        const { min, max } = u.scales.x;
        if (min == null || max == null || max <= min) return;
        onZoom(p.id, min, max);
      }],
      ready: [u => { u._ready = true; }],
    },
  };
}

// Create (or recreate) the uPlot instance for a plot, mounting into p.div
function createUPlot(p) {
  // Destroy previous instance
  if (p.uplot) { p.uplot.destroy(); p.uplot = null; }

  const inTrigMode = trig.enabled && trig.snapshot !== null;
  const opts = makeUPlotOpts(p, inTrigMode);
  const data = buildUPlotData(p, inTrigMode);

  // Mount into the plot body div (clear previous uPlot DOM)
  p.div.querySelectorAll('.uplot').forEach(el => el.remove());

  p.uplot = new uPlot(opts, data, p.div);

  // ── Cursor drag: place or drag A/B cursors ──────────────────────────────
  // - Near an existing cursor line (within CURSOR_SNAP_PX): drag to move it.
  // - cursor mode A or B active: click/drag places that cursor anywhere.
  // - Intercepts mousedown before uPlot zoom so the selection rect never shows.
  const CURSOR_SNAP_PX = 8;

  function _cursorAtClientX(clientX) {
    const rect = p.uplot.over.getBoundingClientRect();
    const { min, max } = p.uplot.scales.x;
    const toX = val => rect.left + ((val - min) / (max - min)) * rect.width;
    if (cursors.tA !== null && Math.abs(clientX - toX(cursors.tA)) <= CURSOR_SNAP_PX) return 'A';
    if (cursors.tB !== null && Math.abs(clientX - toX(cursors.tB)) <= CURSOR_SNAP_PX) return 'B';
    return null;
  }

  function _cursorValFromEvent(e) {
    const rect = p.uplot.over.getBoundingClientRect();
    const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    const { min, max } = p.uplot.scales.x;
    return min + pct * (max - min);
  }

  // Update pointer style based on what's under the mouse
  p.uplot.over.addEventListener('mousemove', e => {
    const snap = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
    p.uplot.over.style.cursor = snap ? 'ew-resize' : '';
  });
  p.uplot.over.addEventListener('mouseleave', () => {
    p.uplot.over.style.cursor = '';
  });

  // Mousedown: drag an existing cursor (only when mode='on' and mouse is near a cursor line).
  // If not near a cursor, the event falls through to uPlot for normal zoom/pan behavior.
  p.uplot.over.addEventListener('mousedown', e => {
    if (e.button !== 0 || e.shiftKey) return; // shift is pan
    if (cursors.mode !== 'on') return;
    const target = _cursorAtClientX(e.clientX);
    if (!target) return; // not near a cursor — let uPlot handle zoom

    e.stopImmediatePropagation(); // prevent uPlot drag-zoom
    e.preventDefault();

    // Set cursor position immediately on mousedown
    if (target === 'A') cursors.tA = _cursorValFromEvent(e);
    else cursors.tB = _cursorValFromEvent(e);
    updateCursorReadout();
    cursorsDirty = true;

    const onMove = ev => {
      if (target === 'A') cursors.tA = _cursorValFromEvent(ev);
      else cursors.tB = _cursorValFromEvent(ev);
      updateCursorReadout();
      cursorsDirty = true;
    };
    const onUp = () => {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
    };
    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
  }, true); // capture:true so we fire before uPlot's own handlers

  // ── Offset marker drag ─────────────────────────────────────────────────────
  // Detect mousedown near the left edge of the plot area (marker triangle zone).
  // Dragging moves the marker AND the signal together by changing screenPos.
  p.div.addEventListener('mousedown', e => {
    if (e.button !== 0 || !p.uplot || !p.uplot.bbox) return;
    const canvas = p.uplot.ctx.canvas;
    const rect = canvas.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    const plotLeftCss = rect.left + p.uplot.bbox.left / dpr;
    const markerZone = 12; // CSS px hit area to left/right of plot edge

    if (e.clientX > plotLeftCss + 3 || e.clientX < plotLeftCss - markerZone) return;

    // Find which marker was hit (closest to screenPos canvas position per signal).
    let hitKey = null, hitDist = Infinity;
    p.traces.forEach(key => {
      const vs = sigVScale[p.id + ':' + key];
      const screenPos = vs ? (vs.screenPos || 0) : 0;
      const yDev = p.uplot.valToPos(screenPos, 'y', true);
      const yCss = rect.top + yDev / dpr;
      const dist = Math.abs(e.clientY - yCss);
      if (dist < 14 && dist < hitDist) { hitDist = dist; hitKey = key; }
    });
    if (!hitKey) return;

    e.preventDefault();
    e.stopPropagation();
    setActiveSig(p.id, hitKey);

    const vs = getVScale(p.id, hitKey);
    const startY = e.clientY;
    const startScreenPos = vs.screenPos || 0;
    const overRect = p.uplot.over.getBoundingClientRect();

    const onMove = ev => {
      const dy = ev.clientY - startY; // positive = down in canvas = lower y_norm
      // Y scale spans 9 divisions over plot height; drag up → higher screenPos.
      const dNorm = -dy / overRect.height * 9;
      vs.screenPos = Math.max(-4, Math.min(4, startScreenPos + dNorm));
      // Keep "Position" input in sync if the vscale menu is open for this signal.
      if (_vsMenuKey === hitKey) {
        document.getElementById('vscale-pos').value = parseFloat(vs.screenPos.toPrecision(4));
      }
      refreshPlotForKey(hitKey);
    };
    const onUp = () => {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
    };
    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
  }, true);

  // Pan support: Shift+left-drag pans the current view (synced across all plots).
  // Works in both zoomed mode (xRange set) and rolling mode (freezes the window first).
  let _panActive = false, _panAnchorX = 0, _panAnchorMin = 0, _panAnchorMax = 0;
  p.uplot.over.addEventListener('mousedown', e => {
    if (e.button !== 0 || !e.shiftKey) return;
    e.stopImmediatePropagation();
    e.preventDefault();
    _panActive = true;
    _panAnchorX = e.clientX;
    const xr = p.xRange;
    if (xr) {
      _panAnchorMin = xr[0];
      _panAnchorMax = xr[1];
    } else {
      // Rolling mode: capture the current window position and freeze it so we
      // have a stable anchor to pan from.
      let now = -Infinity;
      p.traces.forEach(key => {
        const buf = buffers[key];
        if (buf && buf.size > 0) {
          const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
          if (t > now) now = t;
        }
      });
      if (!isFinite(now)) now = Date.now() / 1000;
      _panAnchorMin = now - windowSec;
      _panAnchorMax = now;
      // Freeze all plots at this position immediately.
      const pMin = _panAnchorMin, pMax = _panAnchorMax;
      zoomGuard = true;
      plots.forEach(q => {
        q.xRange = [pMin, pMax];
        if (q.uplot) q.uplot.setScale('x', { min: pMin, max: pMax });
      });
      zoomGuard = false;
      if (!syncLocked) {
        syncLocked = true;
        const btnR = document.getElementById('btn-sync-resume');
        if (btnR) btnR.style.display = '';
      }
    }
  }, true);
  const _onPanMove = e => {
    if (!_panActive || !p.uplot) return;
    const w = p.uplot.over.getBoundingClientRect().width;
    const span = _panAnchorMax - _panAnchorMin;
    const dt = -((e.clientX - _panAnchorX) / w) * span;
    const newMin = _panAnchorMin + dt;
    const newMax = _panAnchorMax + dt;
    zoomGuard = true;
    plots.forEach(q => {
      q.xRange = [newMin, newMax];
      if (q.uplot) q.uplot.setScale('x', { min: newMin, max: newMax });
      q.needsRedraw = true;
    });
    zoomGuard = false;
  };
  const _onPanEnd = () => { _panActive = false; };
  document.addEventListener('mousemove', _onPanMove);
  document.addEventListener('mouseup', _onPanEnd);

  // Resize observer so the uPlot fills its container.
  // zoomGuard prevents setSize → setScale → hook from calling onZoom.
  if (p.ro) { p.ro.disconnect(); }
  p.ro = new ResizeObserver(() => {
    if (!p.uplot) return;
    const w = Math.max(p.div.clientWidth || 50, 50);
    const h = Math.max(p.div.clientHeight || 50, 50);
    zoomGuard = true;
    p.uplot.setSize({ width: w, height: h });
    zoomGuard = false;
  });
  p.ro.observe(p.div);
}

// Build the uPlot data array from buffers / trigger snapshot
function buildUPlotData(p, inTrigMode) {
  if (p.traces.length === 0) return [new Float64Array(0)];

  // When trigger is enabled but no snapshot yet (armed/waiting), return
  // the last-rendered data so the plot stays frozen.
  if (trig.enabled && !inTrigMode) {
    if (!p.uplot || !p.uplot.data || !p.uplot.data[0]) return [new Float64Array(0)];
    return p.uplot.data;
  }

  if (inTrigMode && trig.snapshot) return buildTrigData(p);
  return buildLiveData(p);
}

// Resample (vSrc) from times (tSrc) onto target times (tDst) using linear interpolation.
// tSrc must be sorted ascending.  Values outside tSrc range are clamped to the nearest
// endpoint (extrapolation is not safe for streaming data).
function resampleLinear(tSrc, vSrc, tDst) {
  const n = tDst.length;
  const out = new Float64Array(n);
  if (tSrc.length === 0) return out; // all zeros
  if (tSrc.length === 1) { out.fill(vSrc[0]); return out; }
  let j = 0;
  for (let i = 0; i < n; i++) {
    const td = tDst[i];
    // Advance j so that tSrc[j] <= td < tSrc[j+1] (or j at last index)
    while (j < tSrc.length - 2 && tSrc[j + 1] < td) j++;
    if (td <= tSrc[0]) {
      out[i] = vSrc[0];
    } else if (td >= tSrc[tSrc.length - 1]) {
      out[i] = vSrc[vSrc.length - 1];
    } else {
      const t0 = tSrc[j], t1 = tSrc[j + 1];
      const frac = (td - t0) / (t1 - t0);
      out[i] = vSrc[j] + frac * (vSrc[j + 1] - vSrc[j]);
    }
  }
  return out;
}

function buildLiveData(p) {
  if (p.traces.length === 0) return [new Float64Array(0)];

  const plotNow = computePlotNow(p);
  const t0 = p.xRange ? p.xRange[0] : plotNow - windowSec;
  const t1 = p.xRange ? p.xRange[1] : plotNow;

  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) {
      const fetched = buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
      // Only use server data if it actually has samples; otherwise fall through to local buffer.
      if (fetched[0] && fetched[0].length > 0) return fetched;
    }
  }

  // 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 = sliceFn(buf, t0, t1);
    slices[key] = sl;
    const rate = getKeySamplingRate(key);
    if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
      masterRate = rate; masterCount = sl.t.length; masterKey = key;
    }
  }

  const masterRaw = slices[masterKey];
  if (!masterRaw || masterRaw.t.length === 0)
    return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];

  // Decimate to pixel-adaptive point count in both rolling and zoomed modes.
  // The server sends ≤2000 pts/tick but the buffer accumulates many ticks, so
  // the full window slice can easily reach 100k–300k pts — far more than uPlot
  // needs for a 1200px-wide canvas.  Always run LTTB via the background worker
  // (stale-while-revalidate: use cached result; fall back to sync on first render).
  // Rolling-mode cache key uses _dataGen so the result refreshes on new data.
  const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
  const cacheKey = isRolling
    ? `${p.id}:${masterKey}:rolling:${_dataGen}`
    : `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
  let sharedT, masterV;
  if (masterRaw.t.length <= targetPts) {
    // Data already sparse enough — use directly (no decimation needed).
    sharedT = masterRaw.t;
    masterV = masterRaw.v;
  } else {
    const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
    let dec;
    if (cached) {
      dec = cached;
    } else {
      // Worker job submitted — sync fallback this frame so the plot isn't blank.
      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(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));
  }

  return [sharedT, ...applyVScaleNorm(p, yArrays)];
}

function buildTrigData(p) {
  const trigT = trig.trigTime;
  const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
  const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();

  if (p.traces.length === 0) return [new Float64Array(0)];

  const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS;
  const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS;

  const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);

  // Slice all traces; pick master by samplingRate first, then sample count
  const slices = {};
  let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
  for (const key of p.traces) {
    const snap = trig.snapshot[key];
    if (!snap) continue;
    const sl = sliceTypedArrayRange(snap.t, snap.v, t0, t1);
    slices[key] = sl;
    const rate = getKeySamplingRate(key);
    if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
      masterRate = rate; masterCount = sl.t.length; masterKey = key;
    }
  }

  const masterRaw = slices[masterKey];
  if (!masterRaw || masterRaw.t.length === 0)
    return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];

  // Trig snapshot is fixed (no new data arrives after capture), so the cache
  // key only needs range + data length.  Worker result persists until re-arm.
  const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
  const cachedDec = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
  const dec = cachedDec || lttb(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;

  const yArrays = [];
  for (const key of p.traces) {
    if (key === masterKey) { yArrays.push(dec.v); continue; }
    const sl = slices[key];
    if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
    const relT = new Float64Array(sl.t.length);
    for (let i = 0; i < sl.t.length; i++) relT[i] = sl.t[i] - trigT;
    yArrays.push(resampleLinear(relT, sl.v, sharedT));
  }

  return [sharedT, ...applyVScaleNorm(p, yArrays)];
}

/* ════════════════════════════════════════════════════════════════
   Zoom sync
   ════════════════════════════════════════════════════════════════ */
let syncLocked = false;

function onZoom(sourcePlotId, min, max) {
  // Push current range to history before applying new zoom
  const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
  zoomHistory.push(prevRange);
  if (zoomHistory.length > 30) zoomHistory.shift();
  document.getElementById('btn-zoom-back').style.display = '';

  // Store zoom on source plot
  const src = plots.find(p => p.id === sourcePlotId);
  if (src) src.xRange = [min, max];

  // Show Auto button in live mode
  if (!trig.enabled && !syncLocked) {
    syncLocked = true;
    document.getElementById('btn-sync-resume').style.display = '';
  }

  // Propagate to other plots
  zoomGuard = true;
  plots.forEach(p => {
    if (p.id === sourcePlotId) return;
    p.xRange = [min, max];
    if (p.uplot) p.uplot.setScale('x', { min, max });
  });
  zoomGuard = false;

  // Evict stale LTTB cache entries — new zoom range needs fresh decimation.
  plots.forEach(p => lttbCacheEvict(p.id));

  // 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
function zoomBack() {
  if (!zoomHistory.length) return;
  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();
  } else {
    zoomGuard = true;
    plots.forEach(p => {
      p.xRange = [...prev];
      if (p.uplot) p.uplot.setScale('x', { min: prev[0], max: prev[1] });
      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) {
    const preS = trig.snapshot._preS || trigPreSec();
    const postS = trig.snapshot._postS || trigPostSec();
    zoomGuard = true;
    plots.forEach(p => {
      p.xRange = null;
      if (p.uplot) p.uplot.setScale('x', { min: -preS, max: postS });
      p.needsRedraw = true;
    });
    zoomGuard = false;
  } else {
    // Back to rolling window — setScale to current window, render loop keeps it moving
    plots.forEach(p => {
      p.xRange = null;
      if (!globalPause) p.needsRedraw = true;
    });
  }
}

// Fit x-axis to all data currently in buffers (or full trigger snapshot)
function zoomFit() {
  if (trig.enabled && trig.snapshot) {
    resetZoom(); // "Fit" in trigger mode = show full trigger window
    return;
  }
  // Find oldest/newest timestamps across all visible signals
  let gMin = Infinity, gMax = -Infinity;
  plots.forEach(p => {
    p.traces.forEach(key => {
      const buf = buffers[key]; if (!buf || buf.size === 0) return;
      const startIdx = (buf.size === buf.cap) ? buf.head : 0;
      const oldestT = buf.t[startIdx];
      const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
      if (oldestT < gMin) gMin = oldestT;
      if (newestT > gMax) gMax = newestT;
    });
  });
  if (!isFinite(gMin) || gMin >= gMax) return;

  // Push to history
  const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
  zoomHistory.push(prevRange);
  document.getElementById('btn-zoom-back').style.display = '';
  if (!syncLocked) { syncLocked = true; document.getElementById('btn-sync-resume').style.display = ''; }

  zoomGuard = true;
  plots.forEach(p => {
    p.xRange = [gMin, gMax];
    if (p.uplot) p.uplot.setScale('x', { min: gMin, max: gMax });
    p.needsRedraw = true;
  });
  zoomGuard = false;
  scheduleZoomFetch(gMin, gMax);
}

// Auto = return to rolling window / full trigger window
function exitSyncLock() { resetZoom(); }

document.getElementById('btn-sync-resume').addEventListener('click', resetZoom);
document.getElementById('btn-zoom-back').addEventListener('click', zoomBack);
document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit);

/* ════════════════════════════════════════════════════════════════
   Cursor controls
   ════════════════════════════════════════════════════════════════ */
// Show the cursor button only when paused or in trigger-snapshot mode.
function updateCursorBtnVisibility() {
  const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null);
  const btn = document.getElementById('btn-cursor');
  btn.style.display = canUseCursors ? '' : 'none';
  if (!canUseCursors && cursors.mode !== 'off') {
    cursors.mode = 'off';
    cursors.tA = null; cursors.tB = null; // context changed, clear positions
    btn.textContent = 'Cursors';
    btn.classList.remove('active');
    document.getElementById('cursor-readout').classList.remove('visible');
    cursorsDirty = true;
  }
}

document.getElementById('btn-cursor').addEventListener('click', () => {
  cursors.mode = cursors.mode === 'off' ? 'on' : 'off';
  const btn = document.getElementById('btn-cursor');
  btn.textContent = 'Cursors';
  btn.classList.toggle('active', cursors.mode === 'on');
  if (cursors.mode === 'on') {
    // Auto-place at 25%/75% of current x range on first use
    if (cursors.tA === null && cursors.tB === null) {
      const refPlot = plots.find(p => p.uplot);
      if (refPlot) {
        const { min, max } = refPlot.uplot.scales.x;
        const span = max - min;
        cursors.tA = min + span * 0.25;
        cursors.tB = min + span * 0.75;
      }
    }
    updateCursorReadout();
    document.getElementById('cursor-readout').classList.add('visible');
  } else {
    document.getElementById('cursor-readout').classList.remove('visible');
  }
  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.
  const span = currentXSpan();

  // Format a cursor position: trigger mode → signed relative duration;
  // live mode → absolute wall time with span-appropriate precision.
  const fmt = v => {
    if (v === null) return '—';
    if (trig.enabled && trig.snapshot) return fmtDuration(v, span, true);
    return fmtLiveTime(v, span);
  };

  document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
  document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);

  if (cursors.tA !== null && cursors.tB !== null) {
    const dt = cursors.tB - cursors.tA;
    // ΔT auto-scales by its own magnitude for precision regardless of x-range.
    document.getElementById('cur-dt').textContent = 'ΔT: ' + fmtDuration(dt, Math.abs(dt), true);
  } else {
    document.getElementById('cur-dt').textContent = 'ΔT: —';
  }
}

/* ════════════════════════════════════════════════════════════════
   Trigger bar controls
   ════════════════════════════════════════════════════════════════ */
function openTrigBar(open) {
  trig.enabled = open;
  document.getElementById('trigbar').classList.toggle('open', open);
  document.getElementById('btn-trigger').classList.toggle('trig-active', open);
  document.documentElement.style.setProperty('--trigbar-h', open ? '48px' : '0px');
  // Streaming-only controls are irrelevant while the trigger is active
  const none = open ? 'none' : '';
  document.getElementById('btn-pause-global').style.display = none;
  document.getElementById('window-select').style.display = none;
  document.getElementById('lbl-window').style.display = none;
  if (!open) {
    trigDisarm(); trig.snapshot = null;
    cursors.tA = null; cursors.tB = null; updateCursorReadout();
    updateCursorBtnVisibility();
    zoomHistory.length = 0;
    document.getElementById('btn-zoom-back').style.display = 'none';
    plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
  } else {
    cursors.tA = null; cursors.tB = null; updateCursorReadout();
    updateCursorBtnVisibility();
    zoomHistory.length = 0;
    document.getElementById('btn-zoom-back').style.display = 'none';
    resetZoom();
    if (trig.signal) trigArm(); else updateTrigStatusBadge('idle');
  }
  // Resize uPlot instances after trigbar height changes
  setTimeout(() => {
    plots.forEach(p => {
      if (!p.uplot) return;
      p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
    });
  }, 220);
}
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
document.getElementById('trig-signal').addEventListener('change', e => {
  const val = e.target.value;
  if (!val) { trig.signal = ''; trigDisarm(); return; }
  // Array signal: ask for element index via picker dialog.
  const meta = findSignalMeta(val);
  const n = meta ? numElements(meta) : 1;
  if (meta && !isTemporal(meta) && n > 1) {
    showArrayIdxPicker(val, n, idx => {
      trig.signal = val + '[' + idx + ']'; sendTrigConfig();
      if (trig.enabled) trigArm();
    }, () => {
      // Cancelled: revert selection to current trig.signal base or empty.
      const sel = document.getElementById('trig-signal');
      const base = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
      sel.value = base || '';
    });
    return;
  }
  trig.signal = val; sendTrigConfig();
  if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
});
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; sendTrigConfig(); });
document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; sendTrigConfig(); });
document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); sendTrigConfig(); });
document.getElementById('trig-pre').addEventListener('input', e => {
  trig.prePercent = parseInt(e.target.value, 10);
  document.getElementById('trig-pre-val').textContent = trig.prePercent + '%';
  sendTrigConfig();
});
document.getElementById('trig-mode').addEventListener('change', e => {
  trig.mode = e.target.value;
  sendTrigConfig();
  // If a snapshot already exists the trigger has already fired — update button
  // visibility immediately so it matches the newly selected mode.
  if (trig.snapshot) {
    showRearmBtn(trig.mode === 'single');
    showStopBtn(trig.mode === 'normal');
    updateStopBtn();
  }
});
document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); });
document.getElementById('btn-trig-stop').addEventListener('click', () => {
  if (!trig.enabled || trig.mode !== 'normal') return;
  trig.stopped = !trig.stopped;
  updateStopBtn();
  wsSend({ type: 'trigStop', stopped: trig.stopped });
  if (!trig.stopped) trigArm(); // resume: re-arm immediately
});

/* ════════════════════════════════════════════════════════════════
   Trigger signal selector
   ════════════════════════════════════════════════════════════════ */
function buildTrigSignalSelect() {
  const sel = document.getElementById('trig-signal');
  // Preserve the currently active trig.signal (may include an array index like "[3]").
  const curBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
  sel.innerHTML = '<option value="">— none —</option>';
  Object.values(sourcesMap).forEach(src => {
    const prefix = src.id + ':';
    const srcLabel = src.label || src.addr || src.id;
    (src.signals || []).forEach(sig => {
      const n = numElements(sig);
      const key = prefix + sig.name;
      const o = document.createElement('option');
      o.value = key;
      if (isTemporal(sig) || n === 1) {
        o.textContent = srcLabel + ': ' + sig.name;
      } else {
        // Array: single entry; user chooses index via dialog on selection.
        o.textContent = srcLabel + ': ' + sig.name + ' [0…' + (n - 1) + ']';
      }
      sel.appendChild(o);
    });
  });
  // Restore selection: match base key so array element "sig[3]" selects "sig" option.
  if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase;
  // Do NOT overwrite trig.signal here — an array element selection must be preserved.
}

/* ════════════════════════════════════════════════════════════════
   Sidebar
   ════════════════════════════════════════════════════════════════ */
const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64'];

function buildSidebar() {
  const list = document.getElementById('signal-list');
  list.innerHTML = '';
  const sources = Object.values(sourcesMap);

  sources.forEach(src => {
    const sigs = src.signals || [];
    const prefix = src.id + ':';

    // Source header
    const grp = document.createElement('div');
    grp.className = 'source-group';

    const hdr = document.createElement('div');
    hdr.className = 'source-group-header';

    const dot = document.createElement('span');
    dot.className = 'source-state-dot ' + (src.state || 'disconnected');

    const nameEl = document.createElement('span');
    nameEl.className = 'source-name';
    nameEl.textContent = src.label || src.addr || src.id;
    nameEl.title = src.addr || '';

    const addrEl = document.createElement('span');
    addrEl.className = 'source-addr';
    if (src.label && src.addr) addrEl.textContent = src.addr;

    const rmBtn = document.createElement('button');
    rmBtn.className = 'source-remove-btn';
    rmBtn.title = 'Remove source';
    rmBtn.textContent = '×';
    rmBtn.addEventListener('click', () => removeSource(src.id));

    hdr.append(dot, nameEl, addrEl, rmBtn);
    grp.appendChild(hdr);

    sigs.forEach(sig => {
      const n = numElements(sig), temporal = isTemporal(sig);
      const typeName = _typeNames[sig.typeCode] || '?';
      const globalKey = prefix + sig.name;
      if (n === 1 || temporal) {
        grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
      } else {
        const group = document.createElement('div'); group.className = 'array-group';
        const header = document.createElement('div'); header.className = 'array-header';
        header.innerHTML = '<span class="array-arrow">&#x25B6;</span><span class="sig-name">' + escHtml(sig.name) + '</span>'
          + (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '')
          + '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
        header.addEventListener('click', () => header.classList.toggle('open'));
        const children = document.createElement('div'); children.className = 'array-children';
        for (let i = 0; i < n; i++) {
          const key = globalKey + '[' + i + ']';
          const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');
          child.className = 'array-child'; children.appendChild(child);
        }
        group.appendChild(header); group.appendChild(children); grp.appendChild(group);
      }
    });

    list.appendChild(grp);
  });

  if (!sources.length) {
    const empty = document.createElement('div');
    empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;';
    empty.textContent = 'No sources configured';
    list.appendChild(empty);
  }

  list.appendChild(makeAddSourceSection());
}
function makeDraggable(key, label, typeName, unit) {
  const item = document.createElement('div');
  item.className = 'sig-item'; item.draggable = true;
  item.innerHTML = '<span class="sig-name">' + escHtml(label) + '</span>'
    + (unit ? '<span class="sig-unit">' + escHtml(unit) + '</span>' : '')
    + '<span class="type-badge">' + escHtml(typeName) + '</span>';
  item.addEventListener('dragstart', e => {
    e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy';
    requestAnimationFrame(() => item.classList.add('dragging'));
  });
  item.addEventListener('dragend', () => item.classList.remove('dragging'));
  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
   ════════════════════════════════════════════════════════════════ */
// Returns the number of plot cells in a layout (cols × rows).
function layoutPlotCount(cls) {
  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) {
  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"/>`;
    }
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
    + `<rect width="${W}" height="${H}" rx="2" fill="#11111b"/>`
    + `<g fill="#45475a">${rects}</g></svg>`;
}

// Apply a new layout: switch the grid class and auto-add/remove plots.
// Plots with traces are preserved; empty plots are removed first when shrinking.
function applyLayout(cls) {
  const entry = LAYOUTS.find(l => l[1] === cls);
  if (!entry) return;
  const [label, , cols, rows] = entry;
  currentLayout = 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');
  if (btn) btn.innerHTML = layoutSVG(cols, rows) + ' <span>' + label + '</span> ▾';

  // Update active state in menu
  document.querySelectorAll('.layout-menu-item')
    .forEach(el => el.classList.toggle('active', el.dataset.layout === cls));

  const needed = layoutPlotCount(cls);

  // Remove excess plots — prefer empty ones to preserve trace assignments.
  while (plots.length > needed) {
    let removeId = plots[plots.length - 1].id;
    for (let i = plots.length - 1; i >= 0; i--) {
      if (plots[i].traces.length === 0) { removeId = plots[i].id; break; }
    }
    deletePlot(removeId);
  }

  // Add missing plots (DOM only — uPlot created below after layout settles).
  while (plots.length < needed) addPlot();

  // Recreate all uPlot instances once the CSS grid has sized the cells.
  requestAnimationFrame(() => {
    plots.forEach(p => {
      createUPlot(p);
      p.needsRedraw = true;
    });
    setTimeout(() => plots.forEach(p => {
      if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
    }), 60);
  });
}

function buildLayoutMenu() {
  const menu = document.getElementById('layout-menu');

  LAYOUTS.forEach(([label, cls, cols, rows]) => {
    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.addEventListener('click', () => {
      applyLayout(cls);
      menu.classList.remove('open');
    });
    menu.appendChild(item);
  });

  // Toggle menu open/close
  document.getElementById('btn-layout').addEventListener('click', e => {
    e.stopPropagation();
    const r = e.currentTarget.getBoundingClientRect();
    menu.style.left = r.left + 'px';
    menu.style.top = (r.bottom + 4) + 'px';
    menu.classList.toggle('open');
  });

  // Close when clicking outside
  document.addEventListener('click', e => {
    if (!e.target.closest('#layout-menu') && !e.target.closest('#btn-layout'))
      menu.classList.remove('open');
  });
}

/* ════════════════════════════════════════════════════════════════
   Export CSV (all plots) — fetches full-resolution data from ring
   ════════════════════════════════════════════════════════════════ */
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).
  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).
  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;
  }

  // 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 - relOffset), v: Array.from(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) };
  });

  // 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;

  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);
}

/* ════════════════════════════════════════════════════════════════
   Plot management
   ════════════════════════════════════════════════════════════════ */
function addPlot() {
  const id = nextPlotId++;
  const card = document.createElement('div');
  card.className = 'plot-card'; card.dataset.plotId = id;
  card.innerHTML = `
    <div class="plot-card-header">
      <span class="plot-title" id="ptitle-${id}" title="Click to configure">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}">
      <div class="drop-hint" id="hint-${id}">Drop signals here</div>
      <div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
    </div>`;

  card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; card.classList.add('drag-over'); });
  card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
  card.addEventListener('drop', e => {
    e.preventDefault(); card.classList.remove('drag-over');
    const key = e.dataTransfer.getData('signal'); if (key) addTraceTo(id, key);
  });

  document.getElementById('plot-grid').appendChild(card);

  const plotBody = card.querySelector('#pbody-' + id);
  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;
}

function addTraceTo(plotId, signalKey) {
  const p = plots.find(p => p.id === plotId); if (!p) return;
  if (p.traces.includes(signalKey)) return;
  p.traces.push(signalKey);
  document.querySelector('#hint-' + plotId).style.display = 'none';
  addBadge(plotId, signalKey);
  // Recreate uPlot with new series list
  createUPlot(p);
  p.needsRedraw = true;
  updatePlotCursorReadouts();
}

function removeTraceFrom(plotId, signalKey) {
  const p = plots.find(p => p.id === plotId); if (!p) return;
  p.traces = p.traces.filter(t => t !== signalKey);
  removeBadge(plotId, signalKey);
  // If the removed trace was active, close toolbar and pick a new active signal.
  if (plotActiveSignal[plotId] === signalKey) {
    if (_vsMenuPlotId === plotId) hideVScaleMenu();
    const newActive = p.traces[0] || null;
    if (newActive) setActiveSig(plotId, newActive); else delete plotActiveSignal[plotId];
  }
  createUPlot(p);
  p.needsRedraw = true;
  if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
  updatePlotCursorReadouts();
}

function addBadge(plotId, key) {
  const c = document.getElementById('badges-' + plotId); if (!c) return;
  if (c.querySelector('[data-key="' + CSS.escape(key) + '"]')) return;
  const color = getSigStyle(key).color;
  const badge = document.createElement('span');
  badge.className = 'sig-badge'; badge.dataset.key = key;

  const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color;

  // Show signal name without the "sourceId:" prefix.
  const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
  const nameSpan = document.createElement('span'); nameSpan.textContent = displayName;

  // Small vscale info text (V/div + offset when not in auto mode).
  const infoSpan = document.createElement('span'); infoSpan.className = 'vscale-info';

  const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×';
  x.addEventListener('click', e => { e.stopPropagation(); removeTraceFrom(plotId, key); });

  // Left-click: select + show vscale toolbar; click same signal again to deselect.
  badge.addEventListener('click', e => {
    if (e.target === x) return;
    const isActive = plotActiveSignal[plotId] === key;
    const toolbarVisible = _vsMenuKey === key && _vsMenuPlotId === plotId;
    if (isActive && toolbarVisible) {
      setActiveSig(plotId, null);
      hideVScaleMenu();
    } else {
      setActiveSig(plotId, key);
      showVScaleMenu(key, plotId);
    }
  });
  // Right-click: open signal style (color/width/…) menu.
  badge.addEventListener('contextmenu', e => {
    e.preventDefault();
    showSignalMenu(key, plotId, e.clientX, e.clientY);
  });

  badge.appendChild(dot);
  badge.appendChild(nameSpan);
  badge.appendChild(infoSpan);
  badge.appendChild(x);
  c.appendChild(badge);

  // Auto-activate the first signal added to this plot.
  if (!plotActiveSignal[plotId]) setActiveSig(plotId, key);
}
function removeBadge(plotId, key) {
  const c = document.getElementById('badges-' + plotId); if (!c) return;
  const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (b) b.remove();
}


function deletePlot(plotId) {
  const idx = plots.findIndex(p => p.id === plotId); if (idx === -1) return;
  const p = plots[idx];
  if (p.ro) p.ro.disconnect();
  if (p.uplot) p.uplot.destroy();
  lttbCacheEvict(plotId);
  plots.splice(idx, 1);
  document.querySelector('[data-plot-id="' + plotId + '"]').remove();
}

/* ════════════════════════════════════════════════════════════════
   Render loop
   ════════════════════════════════════════════════════════════════ */
let _dbgTick = 0;
let _dataGen = 0;  // incremented each time new data arrives
function renderDirtyPlots() {
  // Compute global "now" once — shared by all rolling-window plots this frame.
  const globalPlotNow = getGlobalNow();

  // Lightweight diagnostic: every ~10 s log buffer count
  if (++_dbgTick % 300 === 0) {
    let bufCount = 0, totalPts = 0;
    Object.values(buffers).forEach(b => { if (b.size > 0) { bufCount++; totalPts += b.size; } });
    if (bufCount > 0) console.log(`[buf] ${bufCount} buffers, ${totalPts} total pts`);
  }

  // Rolling-window mode: detect stale xRange (buffer has scrolled past a frozen
  // zoom window) and clear it back to rolling.  Run unconditionally before the
  // data-rebuild loop so the stale plot is correctly handled this frame.
  if (!trig.enabled && !globalPause) {
    plots.forEach(p => {
      if (!p.uplot || !p.xRange) return;
      const hasData = p.traces.some(k => buffers[k] && buffers[k].size > 0);
      if (!hasData) return;
      const stale = p.traces.every(key => {
        const buf = buffers[key];
        if (!buf || buf.size === 0) return true;
        const oldest = buf.t[buf.size === buf.cap ? buf.head : 0];
        return p.xRange[1] < oldest;
      });
      if (stale) {
        p.xRange = null;
        syncLocked = false;
        const btnR = document.getElementById('btn-sync-resume');
        if (btnR) btnR.style.display = 'none';
      }
    });
  }

  // Fast path: cursor-only redraw (no data rebuild needed)
  if (cursorsDirty) {
    cursorsDirty = false;
    plots.forEach(p => { if (p.uplot) p.uplot.redraw(false); });
  }

  // Rolling-window plots: mark dirty every frame for smooth continuous scrolling.
  // 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 || p.traces.length === 0) return;
      p.needsRedraw = true;
    });
  }

  plots.forEach(p => {
    if (!p.needsRedraw || !p.uplot || p.traces.length === 0) return;

    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
    // anchored to buffer timestamps so the x-range only advances when
    // signal data actually moves forward.
    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 });
      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
    // entire setData + setScale block so the hook never calls onZoom and freezes
    // p.xRange unintentionally.  The guard is safe here: JS is single-threaded so
    // no genuine user drag-zoom event can fire during this synchronous block.
    zoomGuard = true;
    p.uplot.setData(data);

    // Re-apply the x-scale after setData so the viewport stays correct.
    if (trig.enabled && !inTrigModeNow) {
      // Armed / waiting for trigger: keep the current scale frozen.
    } 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', {
        min: p.xRange ? p.xRange[0] : -preS,
        max: p.xRange ? p.xRange[1] : postS
      });
    } else if (p.xRange) {
      // Zoomed: re-apply so scale is correct after setData
      p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] });
    } else {
      // Rolling window: use same anchor as buildLiveData (min-of-max per source).
      const plotNow = computePlotNow(p);
      p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
    }
    zoomGuard = false;
  });

  // Keep per-plot cursor value readouts in sync with live data.
  if (cursors.mode === 'on') updatePlotCursorReadouts();

  requestAnimationFrame(renderDirtyPlots);
}


/* ════════════════════════════════════════════════════════════════
   Global controls
   ════════════════════════════════════════════════════════════════ */
document.getElementById('btn-pause-global').addEventListener('click', () => {
  globalPause = !globalPause;
  const btn = document.getElementById('btn-pause-global');
  btn.textContent = globalPause ? '▶ Resume' : '⏸ Pause';
  btn.classList.toggle('active', globalPause);
  if (!globalPause) plots.forEach(p => { p.needsRedraw = true; });
  updateCursorBtnVisibility();
});

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
  if (trig.enabled && !trig.snapshot) return;
  plots.forEach(p => { if (!p.xRange) p.needsRedraw = true; });
});

/* ════════════════════════════════════════════════════════════════
   Sidebar toggle
   ════════════════════════════════════════════════════════════════ */
let sidebarOpen = true;
function setSidebar(open) {
  sidebarOpen = open;
  document.getElementById('sidebar').classList.toggle('collapsed', !open);
  document.getElementById('btn-sidebar').classList.toggle('active', open);
  setTimeout(() => {
    plots.forEach(p => {
      if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
    });
  }, 200);
}
document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));

/* ════════════════════════════════════════════════════════════════
   Multi-source management
   ════════════════════════════════════════════════════════════════ */
function onSources(msg) {
  const srcs = msg.sources || [];
  const newIds = new Set(srcs.map(s => s.id));
  // Remove sources that disappeared.
  Object.keys(sourcesMap).forEach(id => {
    if (!newIds.has(id)) {
      const prefix = id + ':';
      Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
      delete sourcesMap[id];
    }
  });
  // Update or create entries.
  srcs.forEach(s => {
    if (!sourcesMap[s.id]) {
      sourcesMap[s.id] = { id: s.id, label: s.label, addr: s.addr, state: s.state, signals: [] };
    } else {
      Object.assign(sourcesMap[s.id], { label: s.label, addr: s.addr, state: s.state });
    }
  });
  buildSidebar();
  if (statsOpen) _refreshStatsSelector();
}

function addSourceWS(label, addr, multicastGroup, dataPort) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    const msg = { type: 'addSource', label, addr };
    if (multicastGroup) { msg.multicastGroup = multicastGroup; }
    if (dataPort)       { msg.dataPort = dataPort; }
    ws.send(JSON.stringify(msg));
  }
}

function removeSource(id) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'removeSource', id }));
  }
}

function saveSourcesWS() {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'saveSources' }));
  }
}

function makeAddSourceSection() {
  const section = document.createElement('div');
  section.className = 'add-source-section';

  const title = document.createElement('div');
  title.className = 'add-source-title';
  title.innerHTML = '<span class="add-src-arrow">&#x25B6;</span> Add Source';

  const body = document.createElement('div');
  body.className = 'add-source-body';

  const addrInput = document.createElement('input');
  addrInput.className = 'add-src-input'; addrInput.type = 'text';
  addrInput.placeholder = 'host:port';

  const labelInput = document.createElement('input');
  labelInput.className = 'add-src-input'; labelInput.type = 'text';
  labelInput.placeholder = 'label (optional)';

  const mcastInput = document.createElement('input');
  mcastInput.className = 'add-src-input'; mcastInput.type = 'text';
  mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)';

  const dataPortInput = document.createElement('input');
  dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number';
  dataPortInput.placeholder = 'data port (multicast only)';
  dataPortInput.min = '1'; dataPortInput.max = '65535';

  const addBtn = document.createElement('button');
  addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
  addBtn.addEventListener('click', () => {
    const addr = addrInput.value.trim(); if (!addr) return;
    const mcastGroup = mcastInput.value.trim();
    const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0;
    addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort);
    addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = '';
  });
  addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });

  const saveBtn = document.createElement('button');
  saveBtn.className = 'add-src-btn save-src-btn';
  saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
  saveBtn.addEventListener('click', saveSourcesWS);

  body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn);
  section.append(title, body);

  title.addEventListener('click', () => {
    const open = section.classList.toggle('open');
    title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : '';
  });

  return section;
}

/* ════════════════════════════════════════════════════════════════
   Utility
   ════════════════════════════════════════════════════════════════ */
function escHtml(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/* ════════════════════════════════════════════════════════════════
   VScale menu (left-click on badge)
   ════════════════════════════════════════════════════════════════ */
let _vsMenuKey = null, _vsMenuPlotId = null;

function showVScaleMenu(key, plotId) {
  hideSignalMenu();
  // If the toolbar was open for a different plot, hide that bar first.
  if (_vsMenuPlotId != null && _vsMenuPlotId !== plotId) {
    const oldBar = document.getElementById('vstb-' + _vsMenuPlotId);
    if (oldBar) oldBar.style.display = 'none';
  }
  _vsMenuKey = key; _vsMenuPlotId = plotId;

  const menu = document.getElementById('vscale-menu');
  const vs = getVScale(_vsMenuPlotId, key);
  const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
  document.getElementById('vscale-menu-key').textContent = displayName;

  document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn =>
    btn.classList.toggle('active', btn.dataset.mode === vs.mode));

  // Disable Range button when signal has no defined range.
  const rangeBtn = document.querySelector('#vscale-mode-btns [data-mode="range"]');
  if (rangeBtn) {
    const meta = findSignalMeta(key);
    const hasRange = meta && meta.rangeMin != null && meta.rangeMax != null;
    rangeBtn.disabled = !hasRange;
    rangeBtn.title = hasRange ? '' : 'No range defined for this signal';
  }

  const isManual = vs.mode === 'manual';
  document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
  document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';

  // Pre-fill V/div with resolved or stored value; Position always shows current screenPos.
  const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
  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) {
    bar.appendChild(menu);
    bar.style.display = 'block';
  }
  menu.style.display = 'block';
}

function hideVScaleMenu() {
  const menu = document.getElementById('vscale-menu');
  // Return the menu element to body so it is detached from any plot card.
  menu.style.display = 'none';
  document.body.appendChild(menu);
  // Hide the vscale bar of the previously active plot.
  if (_vsMenuPlotId != null) {
    const bar = document.getElementById('vstb-' + _vsMenuPlotId);
    if (bar) bar.style.display = 'none';
  }
  _vsMenuKey = null; _vsMenuPlotId = null;
}

/* ─── Array index picker ─────────────────────────────────────────────────── */
let _aipOnConfirm = null, _aipOnCancel = null, _aipMaxIdx = 0;

function showArrayIdxPicker(sigKey, n, onConfirm, onCancel) {
  _aipOnConfirm = onConfirm; _aipOnCancel = onCancel; _aipMaxIdx = n - 1;
  const menu = document.getElementById('array-idx-picker');
  const displayName = sigKey.includes(':') ? sigKey.split(':').slice(1).join(':') : sigKey;
  document.getElementById('aip-sig').textContent = displayName;
  document.getElementById('aip-range').textContent = '(0 – ' + (n - 1) + ')';
  const idxInput = document.getElementById('aip-idx');
  idxInput.max = n - 1; idxInput.value = 0;
  menu.style.display = 'block';
  // Centre the picker on screen.
  const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 120;
  menu.style.left = Math.round((window.innerWidth - mw) / 2) + 'px';
  menu.style.top = Math.round((window.innerHeight - mh) / 3) + 'px';
  idxInput.focus(); idxInput.select();
}

function _aipConfirm() {
  const idxInput = document.getElementById('aip-idx');
  const idx = Math.max(0, Math.min(_aipMaxIdx, parseInt(idxInput.value, 10) || 0));
  document.getElementById('array-idx-picker').style.display = 'none';
  if (_aipOnConfirm) _aipOnConfirm(idx);
  _aipOnConfirm = _aipOnCancel = null;
}

function _aipCancel() {
  document.getElementById('array-idx-picker').style.display = 'none';
  if (_aipOnCancel) _aipOnCancel();
  _aipOnConfirm = _aipOnCancel = null;
}

function initArrayIdxPicker() {
  document.getElementById('aip-ok').addEventListener('click', _aipConfirm);
  document.getElementById('aip-cancel').addEventListener('click', _aipCancel);
  document.getElementById('aip-idx').addEventListener('keydown', e => {
    if (e.key === 'Enter') _aipConfirm();
    if (e.key === 'Escape') _aipCancel();
  });
}

function initVScaleMenu() {
  document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      if (!_vsMenuKey) return;
      const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
      const newMode = btn.dataset.mode;
      if (newMode === 'manual' && vs.mode !== 'manual') {
        // Seed V/div from currently resolved value; screenPos stays as-is.
        vs.divValue = vs._resolvedDiv || 1;
        vs.offset = vs._resolvedOffset || 0; // keep for DC subtraction (internal)
        document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
        document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
      }
      vs.mode = newMode;
      document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      const isManual = vs.mode === 'manual';
      document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
      document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
      refreshPlotForKey(_vsMenuKey);
    });
  });
  document.getElementById('vscale-vdiv').addEventListener('input', e => {
    if (!_vsMenuKey) return;
    const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
    vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
    refreshPlotForKey(_vsMenuKey);
  });
  // "Position (div)" moves the marker and signal together on screen.
  document.getElementById('vscale-pos').addEventListener('input', e => {
    if (!_vsMenuKey) return;
    const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
    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);
}

/* ════════════════════════════════════════════════════════════════
   Signal style context menu
   ════════════════════════════════════════════════════════════════ */
let _ctxMenuKey = null;

function showSignalMenu(key, plotId, x, y) {
  _ctxMenuKey = key;
  const menu = document.getElementById('sig-ctx-menu');
  const style = getSigStyle(key);
  document.getElementById('ctx-menu-key').textContent = key;
  document.getElementById('ctx-color').value = style.color;
  document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => {
    btn.classList.toggle('active', parseFloat(btn.dataset.w) === style.width);
  });
  document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.dash === style.dash);
  });
  document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.marker === style.marker);
  });
  document.getElementById('ctx-marker-size').value = style.markerSize;
  document.getElementById('ctx-marker-size-val').textContent = style.markerSize + 'px';
  menu.style.display = 'block';
  const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 200;
  menu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px';
  menu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px';
}

function hideSignalMenu() {
  document.getElementById('sig-ctx-menu').style.display = 'none';
  _ctxMenuKey = null;
}

function initSignalMenu() {
  document.getElementById('ctx-color').addEventListener('input', e => {
    if (!_ctxMenuKey) return;
    setSigStyle(_ctxMenuKey, { color: e.target.value });
  });
  document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      if (!_ctxMenuKey) return;
      setSigStyle(_ctxMenuKey, { width: parseFloat(btn.dataset.w) });
      document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
    });
  });
  document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      if (!_ctxMenuKey) return;
      setSigStyle(_ctxMenuKey, { dash: btn.dataset.dash });
      document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
    });
  });
  document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      if (!_ctxMenuKey) return;
      setSigStyle(_ctxMenuKey, { marker: btn.dataset.marker });
      document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
    });
  });
  document.getElementById('ctx-marker-size').addEventListener('input', e => {
    const sz = parseInt(e.target.value, 10);
    document.getElementById('ctx-marker-size-val').textContent = sz + 'px';
    if (!_ctxMenuKey) return;
    setSigStyle(_ctxMenuKey, { markerSize: sz });
  });
  document.addEventListener('click', e => {
    if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu();
  });
  document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideSignalMenu(); hideVScaleMenu(); } });
}

/* ════════════════════════════════════════════════════════════════
   Source Statistics panel
   ════════════════════════════════════════════════════════════════ */
const sourceStats = {};   // sourceId → StatInfo (from backend 1 Hz message)
let statsOpen = false;
let statsSelectedSrc = null;  // currently displayed source id

function onStats(msg) {
  const incoming = msg.sources || {};
  Object.keys(incoming).forEach(id => { sourceStats[id] = incoming[id]; });
  if (statsOpen) renderStats();
}

// Rebuild the source selector options; preserve selection when possible.
function _refreshStatsSelector() {
  const sel = document.getElementById('stats-source-sel');
  if (!sel) return;
  const prev = statsSelectedSrc;
  sel.innerHTML = '';
  const srcs = Object.values(sourcesMap);
  srcs.forEach(src => {
    const opt = document.createElement('option');
    opt.value = src.id;
    opt.textContent = src.label || src.id;
    sel.appendChild(opt);
  });
  // Restore previous selection or default to first
  if (prev && sourcesMap[prev]) {
    sel.value = prev;
  } else if (srcs.length > 0) {
    sel.value = srcs[0].id;
  }
  statsSelectedSrc = sel.value || null;
}

// Frontend latency for one source: wallNow − newest calibrated buffer timestamp.
function sourceLatencyMs(srcId) {
  const prefix = srcId + ':';
  const wallNow = Date.now() / 1000;
  let best = null;
  Object.keys(buffers).forEach(key => {
    if (!key.startsWith(prefix)) return;
    const buf = buffers[key];
    if (!buf || buf.size === 0) return;
    const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
    const lag = (wallNow - newest) * 1000;
    if (best === null || lag < best) best = lag;
  });
  return best;
}

function _fmtMs(v) { return v != null && isFinite(v) ? v.toFixed(2) + ' ms' : '—'; }
function _fmtHz(v) { return v != null && isFinite(v) && v > 0 ? v.toFixed(2) + ' Hz' : '—'; }
function _fmtKB(v) { return v != null && isFinite(v) ? (v / 1024).toFixed(2) + ' KB' : '—'; }

function _statsKV(label, value, cls) {
  return `<div class="stats-kv"><span class="stats-k">${escHtml(label)}</span><span class="stats-v${cls ? ' ' + cls : ''}">${escHtml(value)}</span></div>`;
}

function _histHTML(si) {
  if (!si.cycleHist || !si.cycleHist.length) return '';
  const maxC = Math.max(...si.cycleHist, 1);
  const bars = si.cycleHist.map(c => {
    const pct = Math.max(Math.round((c / maxC) * 100), 1);
    return `<div class="hist-bar" style="height:${pct}%" title="${c} samples"></div>`;
  }).join('');
  return `<div class="stats-hist">
    <div class="hist-bars">${bars}</div>
    <div class="hist-labels"><span>${si.cycleHistMin.toFixed(3)}</span><span>${si.cycleHistMax.toFixed(3)} ms</span></div>
  </div>`;
}

function renderStats() {
  const body = document.getElementById('stats-body');
  if (!body) return;

  const src = statsSelectedSrc ? sourcesMap[statsSelectedSrc] : null;
  if (!src) {
    body.innerHTML = '<span class="stats-empty">No source selected</span>';
    return;
  }

  const si = sourceStats[src.id];
  const latMs = sourceLatencyMs(src.id);
  const lossColor = si && si.totalLost > 0 ? 'warn' : 'ok';
  const lossText = si ? `${si.totalLost} / ${si.totalReceived}` : '—';

  body.innerHTML = `
    <div class="stats-section">
      <div class="stats-section-label">Connection</div>
      <div class="stats-row">
        ${_statsKV('Address', src.addr)}
        ${_statsKV('Latency', _fmtMs(latMs))}
        ${_statsKV('Lost / Rx', lossText, lossColor)}
        ${_statsKV('Loss %', si && si.totalReceived > 0 ? (si.totalLost / si.totalReceived * 100).toFixed(2) + ' %' : '—', si && si.totalLost > 0 ? 'warn' : 'ok')}
      </div>
    </div>
    <hr class="stats-sep">
    <div class="stats-section">
      <div class="stats-section-label">Cycle rate</div>
      <div class="stats-row">
        ${_statsKV('avg', si ? _fmtHz(si.rateHz) : '—')}
        ${_statsKV('± σ', si ? _fmtHz(si.rateStdHz) : '—')}
        ${_statsKV('Pkts / cycle', si ? si.fragsPerCycle.toFixed(1) : '—')}
        ${_statsKV('KB / cycle', si ? _fmtKB(si.bytesPerCycle) : '—')}
      </div>
    </div>
    <hr class="stats-sep">
    <div class="stats-section stats-section-grow">
      <div class="stats-section-label">Cycle time histogram</div>
      <div class="stats-row" style="margin-bottom:6px">
        ${_statsKV('avg', si ? _fmtMs(si.cycleAvgMs) : '—')}
        ${_statsKV('σ', si ? _fmtMs(si.cycleStdMs) : '—')}
        ${_statsKV('min', si ? _fmtMs(si.cycleMinMs) : '—')}
        ${_statsKV('max', si ? _fmtMs(si.cycleMaxMs) : '—')}
      </div>
      ${si ? _histHTML(si) : '<span class="stats-empty">No data yet</span>'}
    </div>`;
}

function toggleStats() {
  statsOpen = !statsOpen;
  document.getElementById('stats-panel').classList.toggle('open', statsOpen);
  document.getElementById('btn-stats').classList.toggle('active', statsOpen);
  if (statsOpen) {
    _refreshStatsSelector();
    renderStats();
  }
}

// Refresh latency and stats every second while panel is open.
setInterval(() => { if (statsOpen) renderStats(); }, 1000);

/* ════════════════════════════════════════════════════════════════
   Init
   ════════════════════════════════════════════════════════════════ */
buildLayoutMenu();
applyLayout('l1x1');
buildSidebar();   // show "Add Source" section even before WS connection
initArrayIdxPicker();
initVScaleMenu();
initSignalMenu();
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
document.getElementById('btn-stats').addEventListener('click', toggleStats);
document.getElementById('btn-stats-close').addEventListener('click', toggleStats);
document.getElementById('stats-source-sel').addEventListener('change', e => {
  statsSelectedSrc = e.target.value || null;
  renderStats();
});
resolveHub().then(connectWS);
requestAnimationFrame(renderDirtyPlots);
fetch('/version').then(r => r.text()).then(v => {
  document.getElementById('build-version').textContent = 'v' + v;
}).catch(() => { });
