diff --git a/Client/WebUI/hub.go b/Client/WebUI/hub.go index 67fa7d6..0fc40b8 100644 --- a/Client/WebUI/hub.go +++ b/Client/WebUI/hub.go @@ -82,9 +82,14 @@ func (c *wsClient) readPump() { case "addSource": label, _ := env["label"].(string) addr, _ := env["addr"].(string) + mcastGroup, _ := env["multicastGroup"].(string) + dataPortF, _ := env["dataPort"].(float64) if addr != "" { select { - case c.hub.commandCh <- hubCmd{op: "wsAddSource", label: label, addr: addr}: + case c.hub.commandCh <- hubCmd{ + op: "wsAddSource", label: label, addr: addr, + multicastGroup: mcastGroup, dataPort: int(dataPortF), + }: default: } } @@ -139,11 +144,13 @@ type taggedSample struct { type hubCmd struct { op string // "addSource","removeSource","setSourceState","updateConfig", // "wsAddSource","wsRemoveSource","wsSaveSources" - sourceID string - label string - addr string - state string - sigs []SignalInfo + sourceID string + label string + addr string + state string + sigs []SignalInfo + multicastGroup string + dataPort int } // Hub is the central broker between UDP clients and WebSocket clients. @@ -474,7 +481,9 @@ func (h *Hub) Run() { case "wsAddSource": if h.sm != nil { - go func(label, addr string) { h.sm.Add(label, addr, "", 0) }(cmd.label, cmd.addr) + go func(label, addr, mcastGroup string, dataPort int) { + h.sm.Add(label, addr, mcastGroup, dataPort) + }(cmd.label, cmd.addr, cmd.multicastGroup, cmd.dataPort) } case "wsRemoveSource": @@ -718,9 +727,10 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample) decimT, decimV := lttbDecimate(allT, allV, maxPushPoints) out[pfx+sig.Name] = sigData{T: decimT, V: decimV} - case n > 1 && sig.TimeMode == TimeModeFullArray: - // The time signal has the same N elements as the data signal. + case sig.TimeMode == TimeModeFullArray: // Each element pair (timeSig[k], dataSig[k]) is one (t, v) sample. + // This handles both standard N-element FullArray signals and + // Accumulate-mode scalars (n=1) auto-assigned FullArray time mode. hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs) var timeSigName string timerToSec := 1e-6 @@ -918,7 +928,9 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS decimT, decimV := lttbDecimate(allT, allV, maxPushPoints) pairs[sig.Name] = pairBuf{t: decimT, v: decimV} - case n > 1 && sig.TimeMode == TimeModeFullArray: + case sig.TimeMode == TimeModeFullArray: + // Handles both standard N-element FullArray signals and + // Accumulate-mode scalars (n=1) with auto-assigned FullArray time mode. hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs) var timeSigName string timerToSec := 1e-6 @@ -957,9 +969,11 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS allV = append(allV, vals[k]) } } - ringT, ringV := lttbDecimate(allT, allV, maxRingPoints) - if rb := h.getRing(pfx + sig.Name); rb != nil { - rb.write(ringT, ringV) + if writeRing { + ringT, ringV := lttbDecimate(allT, allV, maxRingPoints) + if rb := h.getRing(pfx + sig.Name); rb != nil { + rb.write(ringT, ringV) + } } decimT, decimV := lttbDecimate(allT, allV, maxPushPoints) pairs[sig.Name] = pairBuf{t: decimT, v: decimV} diff --git a/Client/WebUI/main.go b/Client/WebUI/main.go index f3aa5fa..6781b27 100644 --- a/Client/WebUI/main.go +++ b/Client/WebUI/main.go @@ -43,8 +43,8 @@ func main() { } } for _, arg := range sourceArgs { - label, addr := ParseSourceArg(arg) - sm.Add(label, addr, "", 0) + label, addr, mcastGroup, dataPort := ParseSourceArgFull(arg) + sm.Add(label, addr, mcastGroup, dataPort) } sub, err := fs.Sub(staticFiles, "static") diff --git a/Client/WebUI/protocol.go b/Client/WebUI/protocol.go index a364482..b46d5ca 100644 --- a/Client/WebUI/protocol.go +++ b/Client/WebUI/protocol.go @@ -34,6 +34,11 @@ const ( TimeModeFullArray uint8 = 1 // TimeSignal has same N elements; not expanded here TimeModeFirstSample uint8 = 2 // TimeSignal scalar = time of element [0] TimeModeLastSample uint8 = 3 // TimeSignal scalar = time of element [N-1] + + // PublishMode values – must match UDPStreamerPublishMode enum in UDPStreamer.h + PublishModeStrict uint8 = 0 // one packet per Synchronise() call + PublishModeAccumulate uint8 = 1 // variable batch; DATA has [8 HRT][4 numSamples][signals...] + PublishModeDecimate uint8 = 2 // one packet every Ratio calls ) // ─── Packet header (17 bytes, little-endian, packed) ───────────────────────── @@ -216,16 +221,17 @@ func nullTermString(b []byte) string { // ─── CONFIG payload parser ──────────────────────────────────────────────────── // ParseConfig decodes a fully-reassembled CONFIG payload. -func ParseConfig(payload []byte) ([]SignalInfo, error) { +// Returns the signal list, the publishing mode byte (PublishMode*), and any error. +func ParseConfig(payload []byte) ([]SignalInfo, uint8, error) { if len(payload) < 4 { - return nil, fmt.Errorf("config payload too short") + return nil, 0, fmt.Errorf("config payload too short") } numSigs := binary.LittleEndian.Uint32(payload[0:4]) offset := 4 sigs := make([]SignalInfo, 0, numSigs) for i := uint32(0); i < numSigs; i++ { if offset+SigDescSize > len(payload) { - return nil, fmt.Errorf("config payload truncated at signal %d", i) + return nil, 0, fmt.Errorf("config payload truncated at signal %d", i) } raw := payload[offset : offset+SigDescSize] si := SignalInfo{ @@ -245,7 +251,12 @@ func ParseConfig(payload []byte) ([]SignalInfo, error) { sigs = append(sigs, si) offset += SigDescSize } - return sigs, nil + // Trailing publish-mode byte (added after signal descriptors). + publishMode := PublishModeStrict + if offset < len(payload) { + publishMode = payload[offset] + } + return sigs, publishMode, nil } // ─── DATA payload parser ────────────────────────────────────────────────────── @@ -257,48 +268,116 @@ type DataSample struct { Values map[string][]float64 // key = signal name, value = []float64 with NumElements entries } -// ParseData decodes a fully-reassembled DATA payload using the provided signal config. -// arrivalTime is the wall-clock time at which the packet was received. -func ParseData(payload []byte, sigs []SignalInfo, arrivalTime time.Time) (DataSample, error) { +// parseElems reads n elements for sig from payload at offset, advancing offset. +// Returns the slice of float64 values and the new offset. +func parseElems(payload []byte, offset, n int, sig SignalInfo) ([]float64, int, error) { + elems := make([]float64, n) + if sig.QuantType == QuantNone { + sz := rawTypeSize(sig.TypeCode) + needed := n * sz + if offset+needed > len(payload) { + return nil, offset, fmt.Errorf("data payload truncated for signal %q", sig.Name) + } + for i := 0; i < n; i++ { + elems[i] = readRawElement(payload, offset+i*sz, sig.TypeCode) + } + offset += needed + } else { + sz := quantSize(sig.QuantType) + needed := n * sz + if offset+needed > len(payload) { + return nil, offset, fmt.Errorf("data payload truncated (quant) for signal %q", sig.Name) + } + for i := 0; i < n; i++ { + var raw uint16 + if sz == 1 { + raw = uint16(payload[offset+i]) + } else { + raw = binary.LittleEndian.Uint16(payload[offset+i*2:]) + } + elems[i] = dequantise(sig.QuantType, raw, sig.RangeMin, sig.RangeMax) + } + offset += needed + } + return elems, offset, nil +} + +// ParseData decodes a fully-reassembled DATA payload using the provided signal config +// and publishing mode. arrivalTime is the wall-clock time at which the packet arrived. +// +// For PublishModeAccumulate the payload format is: +// +// [8 HRT][4 numSamples][for each signal: accumulated scalars → numSamples elems; arrays → NumElements elems] +// +// The function returns one DataSample per accumulated snapshot so the hub can +// process each slot independently with its own timestamp. +func ParseData(payload []byte, sigs []SignalInfo, publishMode uint8, arrivalTime time.Time) ([]DataSample, error) { if len(payload) < 8 { - return DataSample{}, fmt.Errorf("data payload too short") + return nil, fmt.Errorf("data payload too short") } hrt := binary.LittleEndian.Uint64(payload[0:8]) offset := 8 + if publishMode == PublishModeAccumulate { + if len(payload) < 12 { + return nil, fmt.Errorf("accumulate data payload too short (missing numSamples)") + } + numSamples := int(binary.LittleEndian.Uint32(payload[8:12])) + offset = 12 + if numSamples == 0 { + return []DataSample{}, nil + } + + // Parse per-signal data blocks (all slots for a signal are contiguous). + accumVals := make(map[string][]float64, len(sigs)) // scalars: numSamples values + fixedVals := make(map[string][]float64, len(sigs)) // arrays: NumElements values + + for _, sig := range sigs { + n := sig.NumElements() + if n == 1 { + // Accumulated scalar: read numSamples back-to-back elements. + elems, newOff, err := parseElems(payload, offset, numSamples, sig) + if err != nil { + return nil, err + } + offset = newOff + accumVals[sig.Name] = elems + } else { + // Fixed array (non-accumulated): one set of NumElements values. + elems, newOff, err := parseElems(payload, offset, n, sig) + if err != nil { + return nil, err + } + offset = newOff + fixedVals[sig.Name] = elems + } + } + + // Build one DataSample per slot. + samples := make([]DataSample, numSamples) + for k := 0; k < numSamples; k++ { + vals := make(map[string][]float64, len(sigs)) + for sigName, av := range accumVals { + vals[sigName] = []float64{av[k]} + } + for sigName, fv := range fixedVals { + vals[sigName] = fv // shared read-only reference; hub does not modify + } + samples[k] = DataSample{HRTTimestamp: hrt, WallTime: arrivalTime, Values: vals} + } + return samples, nil + } + + // Strict / Decimate: single snapshot, one element set per signal. vals := make(map[string][]float64, len(sigs)) for _, sig := range sigs { n := sig.NumElements() - elems := make([]float64, n) - - if sig.QuantType == QuantNone { - sz := rawTypeSize(sig.TypeCode) - needed := n * sz - if offset+needed > len(payload) { - return DataSample{}, fmt.Errorf("data payload truncated for signal %q", sig.Name) - } - for i := 0; i < n; i++ { - elems[i] = readRawElement(payload, offset+i*sz, sig.TypeCode) - } - offset += needed - } else { - sz := quantSize(sig.QuantType) - needed := n * sz - if offset+needed > len(payload) { - return DataSample{}, fmt.Errorf("data payload truncated (quant) for signal %q", sig.Name) - } - for i := 0; i < n; i++ { - var raw uint16 - if sz == 1 { - raw = uint16(payload[offset+i]) - } else { - raw = binary.LittleEndian.Uint16(payload[offset+i*2:]) - } - elems[i] = dequantise(sig.QuantType, raw, sig.RangeMin, sig.RangeMax) - } - offset += needed + elems, newOff, err := parseElems(payload, offset, n, sig) + if err != nil { + return nil, err } + offset = newOff vals[sig.Name] = elems } - return DataSample{HRTTimestamp: hrt, WallTime: arrivalTime, Values: vals}, nil + return []DataSample{{HRTTimestamp: hrt, WallTime: arrivalTime, Values: vals}}, nil } diff --git a/Client/WebUI/sources.go b/Client/WebUI/sources.go index 121b1e3..3209fb2 100644 --- a/Client/WebUI/sources.go +++ b/Client/WebUI/sources.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "sync" "sync/atomic" @@ -135,9 +136,33 @@ func (sm *SourceManager) Load(path string) error { // ParseSourceArg parses "label@host:port" or "host:port". func ParseSourceArg(s string) (label, addr string) { - s = strings.TrimSpace(s) - if idx := strings.Index(s, "@"); idx >= 0 { - return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+1:]) - } - return "", s + label, addr, _, _ = ParseSourceArgFull(s) + return +} + +// ParseSourceArgFull parses "[label@]host:port[/multicastGroup:dataPort]". +// Examples: +// +// "Streamer@127.0.0.1:44500/239.0.0.1:44503" → label="Streamer", addr="127.0.0.1:44500", group="239.0.0.1", port=44503 +// "127.0.0.1:44501" → label="", addr="127.0.0.1:44501", group="", port=0 +func ParseSourceArgFull(s string) (label, addr, multicastGroup string, dataPort int) { + s = strings.TrimSpace(s) + rest := s + if idx := strings.Index(s, "@"); idx >= 0 { + label = strings.TrimSpace(s[:idx]) + rest = strings.TrimSpace(s[idx+1:]) + } + if idx := strings.Index(rest, "/"); idx >= 0 { + addr = strings.TrimSpace(rest[:idx]) + mcastPart := strings.TrimSpace(rest[idx+1:]) + if lastColon := strings.LastIndex(mcastPart, ":"); lastColon >= 0 { + multicastGroup = strings.TrimSpace(mcastPart[:lastColon]) + dataPort, _ = strconv.Atoi(strings.TrimSpace(mcastPart[lastColon+1:])) + } else { + multicastGroup = mcastPart + } + } else { + addr = rest + } + return } diff --git a/Client/WebUI/static/app.js b/Client/WebUI/static/app.js index 3cde343..362abae 100644 --- a/Client/WebUI/static/app.js +++ b/Client/WebUI/static/app.js @@ -39,6 +39,12 @@ 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); @@ -53,6 +59,121 @@ function setSigStyle(key, updates) { 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 }; + 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 }; +} + +// 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) { + 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; + }); +} + +// 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); +} + +// 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'); @@ -692,32 +813,83 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) { if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; } yArrays.push(resampleLinear(sd.t, sd.v, sharedT)); } - return [sharedT, ...yArrays]; + return [sharedT, ...applyVScaleNorm(p, yArrays)]; } /* ════════════════════════════════════════════════════════════════ uPlot helpers ════════════════════════════════════════════════════════════════ */ -// Format Unix seconds → HH:MM:SS.mmm (used for live x-axis ticks) -function fmtLiveTick(u, vals) { - return vals.map(v => { - if (v == null) return ''; - 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'); +// ─── 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 relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks) + +// 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) { - return vals.map(v => { - if (v == null) return ''; - const abs = Math.abs(v), sign = v < 0 ? '-' : '+'; - if (abs < 1) return sign + (abs * 1000).toFixed(1) + 'ms'; - return sign + abs.toFixed(3) + 's'; - }); + 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 @@ -745,7 +917,16 @@ function drawTriggerMarker(u, p) { 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)) { - const y = u.valToPos(trig.threshold, 'y', true); + // 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)'; @@ -778,6 +959,72 @@ function interpAtTime(u, si, t) { return v0 + (t - t0) / (t1 - t0) * (v1 - v0); } +// Redraw the active signal's line on top of all series with a wider stroke, so it +// visually appears in the foreground regardless of series draw order. +function drawActiveSeries(u, p) { + if (!u.bbox) return; + 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) 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) { @@ -855,10 +1102,20 @@ function drawCursorLines(u, p) { if (p) { const DSZ = 5; // diamond half-size in px p.traces.forEach((key, idx) => { - const v = interpAtTime(u, idx + 1, val); - if (v === null) return; - const cy = u.valToPos(v, 'y', true); + 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; @@ -871,13 +1128,13 @@ function drawCursorLines(u, p) { ctx.lineTo(px - DSZ, cy); ctx.closePath(); ctx.fill(); - // Value text next to diamond - const str = Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString(); + // 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"; // horizontal alignment - ctx.textBaseline = "middle"; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; ctx.fillText(str, px + DSZ + 4, cy); ctx.textAlign = currentAlign; }); @@ -955,20 +1212,41 @@ function makeUPlotOpts(p, inTrigMode) { } return { time: false, auto: false, min: xMin, max: xMax }; })(), - y: { auto: true }, + 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, space: 90 + 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, + // Fixed 9 splits at integer divisions [-4..4] matching the normalized Y scale. + splits: () => [-4, -3, -2, -1, 0, 1, 2, 3, 4], + // Labels show real-unit values of the active signal for the plot. + // y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset + values: (u, vals) => { + 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)); + }, }, - { stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 50 }, ], legend: { show: false }, padding: [4, 4, 0, 0], hooks: { - draw: [u => { drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }], + draw: [u => { 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 @@ -1062,6 +1340,59 @@ function createUPlot(p) { 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; @@ -1191,7 +1522,9 @@ function buildLiveData(p) { if (p.xRange) { const zd = zoomData[p.id]; if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) { - return buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2)); + 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; } } @@ -1249,7 +1582,7 @@ function buildLiveData(p) { yArrays.push(resampleLinear(sl.t, sl.v, sharedT)); } - return [sharedT, ...yArrays]; + return [sharedT, ...applyVScaleNorm(p, yArrays)]; } function buildTrigData(p) { @@ -1301,7 +1634,7 @@ function buildTrigData(p) { yArrays.push(resampleLinear(relT, sl.v, sharedT)); } - return [sharedT, ...yArrays]; + return [sharedT, ...applyVScaleNorm(p, yArrays)]; } /* ════════════════════════════════════════════════════════════════ @@ -1486,26 +1819,24 @@ function updateCursorReadout() { ro.classList.toggle('visible', active); if (!active) return; - // Format depends on mode: live = HH:MM:SS.mmm, trigger = ±Xms + // 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) { - const abs = Math.abs(v), sign = v < 0 ? '-' : '+'; - return abs < 1 ? sign + (abs * 1000).toFixed(3) + 'ms' : sign + abs.toFixed(6) + 's'; - } - const d = new Date(v * 1000); - return String(d.getHours()).padStart(2, '0') + ':' - + String(d.getMinutes()).padStart(2, '0') + ':' - + String(d.getSeconds()).padStart(2, '0') + '.' - + String(d.getMilliseconds()).padStart(3, '0'); + 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, abs = Math.abs(dt); - const s = dt >= 0 ? '+' : '-'; - const str = abs < 1 ? s + (abs * 1000).toFixed(3) + 'ms' : s + abs.toFixed(6) + 's'; - document.getElementById('cur-dt').textContent = 'ΔT: ' + str; + 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: —'; } @@ -1549,7 +1880,24 @@ function openTrigBar(open) { } document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled)); document.getElementById('trig-signal').addEventListener('change', e => { - trig.signal = e.target.value; trig.prevVal = null; + 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 + ']'; trig.prevVal = null; + 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; trig.prevVal = null; if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm(); }); document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; }); @@ -1581,28 +1929,30 @@ document.getElementById('btn-trig-stop').addEventListener('click', () => { Trigger signal selector ════════════════════════════════════════════════════════════════ */ function buildTrigSignalSelect() { - const sel = document.getElementById('trig-signal'), cur = sel.value; + 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 = ''; 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) { - const key = prefix + sig.name; - const o = document.createElement('option'); - o.value = key; o.textContent = srcLabel + ': ' + sig.name; sel.appendChild(o); + o.textContent = srcLabel + ': ' + sig.name; } else { - for (let i = 0; i < n; i++) { - const key = prefix + sig.name + '[' + i + ']'; - const o = document.createElement('option'); - o.value = key; o.textContent = srcLabel + ': ' + sig.name + '[' + i + ']'; sel.appendChild(o); - } + // Array: single entry; user chooses index via dialog on selection. + o.textContent = srcLabel + ': ' + sig.name + ' [0…' + (n - 1) + ']'; } + sel.appendChild(o); }); }); - if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur; - trig.signal = sel.value; + // 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. } /* ════════════════════════════════════════════════════════════════ @@ -1919,6 +2269,7 @@ function addPlot() {