Fix WebUI rolling-window and buffer-wrap display bugs

Replace fragile zoomGuard boolean with userInteracting flag so that
programmatic scale updates (rolling window, resize, fit) never lock
p.xRange.  Only genuine mouse drag or scroll-wheel events on the uPlot
canvas set userInteracting=true and allow onZoom to freeze the view.

Also move stale-xRange detection out of the needsRedraw gate so that a
plot whose circular buffer has scrolled past a frozen zoom range
automatically returns to rolling-window mode every frame, fixing the
second bug where data disappeared as the buffer wrapped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-05-17 23:48:31 +02:00
parent e258b9eb7f
commit 82005ec5d3
3 changed files with 1292 additions and 765 deletions
+79 -15
View File
@@ -90,6 +90,7 @@ type Hub struct {
mu sync.RWMutex
signals []SignalInfo
configJS []byte // cached JSON config message
configSeq uint64 // incremented on every UpdateConfig call
clients map[*wsClient]bool
register chan *wsClient
@@ -97,16 +98,26 @@ type Hub struct {
broadcastCh chan []byte // all sends go through Run() to avoid races on c.send
dataCh chan DataSample // incoming samples from UDP goroutine
// Time-signal calibration: only accessed from the Run() goroutine.
// For temporal-array signals (TimeMode=FirstSample/LastSample), the
// MARTe2 Time signal value (uint32 microseconds from start) is used as
// the timing anchor. We calibrate a per-signal wall-clock offset once
// on the first data packet so that subsequent packets are stamped purely
// from the embedded timer value → perfect continuity, no jitter gaps.
timeSigCalib map[string]float64 // key=time-signal name, value=wallTime-timerSecs offset
configSeqAtCalib uint64 // configSeq value when timeSigCalib was last reset
}
// NewHub creates an initialised Hub.
func NewHub() *Hub {
return &Hub{
clients: make(map[*wsClient]bool),
register: make(chan *wsClient, 8),
unregister: make(chan *wsClient, 8),
broadcastCh: make(chan []byte, 64),
dataCh: make(chan DataSample, 256),
clients: make(map[*wsClient]bool),
register: make(chan *wsClient, 8),
unregister: make(chan *wsClient, 8),
broadcastCh: make(chan []byte, 64),
dataCh: make(chan DataSample, 256),
timeSigCalib: make(map[string]float64),
}
}
@@ -123,6 +134,7 @@ func (h *Hub) UpdateConfig(sigs []SignalInfo) {
h.mu.Lock()
h.signals = sigs
h.configJS = msg
h.configSeq++
h.mu.Unlock()
h.broadcast(msg)
@@ -256,10 +268,11 @@ type dataMsg struct {
//
// Three cases are handled:
//
// 1. Temporal array (NumElements > 1, TimeMode != PacketTime):
// 1. Temporal array (NumElements > 1, TimeMode == FirstSample or LastSample):
// The N samples in each packet represent a contiguous time burst at SamplingRate.
// Wall arrival time is treated as the timestamp of the last sample; earlier
// samples are reconstructed as t[k] = wallT - (N-1-k)/SamplingRate.
// The embedded TimeSignal value (uint32 microseconds) is used as the timing
// anchor for each burst. A wall-clock offset is calibrated once on the first
// packet so that abs-time stays consistent with other signals.
// The full expanded stream is decimated to maxBatchPoints if needed.
//
// 2. Scalar signal (NumElements == 1):
@@ -273,32 +286,83 @@ func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
return nil
}
// Reset time-signal calibration whenever the config has changed.
h.mu.RLock()
seq := h.configSeq
h.mu.RUnlock()
if seq != h.configSeqAtCalib {
h.configSeqAtCalib = seq
h.timeSigCalib = make(map[string]float64)
}
out := make(map[string]sigData, len(sigs)*2)
for _, sig := range sigs {
n := sig.NumElements()
isTemporal := n > 1 && sig.TimeMode != 0
isTemporal := n > 1 && (sig.TimeMode == TimeModeFirstSample || sig.TimeMode == TimeModeLastSample)
switch {
case isTemporal:
// Expand each packet's N samples into individual time-stamped points.
allT := make([]float64, 0, len(batch)*n)
allV := make([]float64, 0, len(batch)*n)
// Resolve time signal (scalar that gives the anchor time in microseconds).
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
var timeSigName string
if hasTimeSig {
timeSigName = sigs[sig.TimeSignalIdx].Name
}
dt := 0.0
if sig.SamplingRate > 0 {
dt = 1.0 / sig.SamplingRate
}
// Expand each packet's N samples into individual time-stamped points.
allT := make([]float64, 0, len(batch)*n)
allV := make([]float64, 0, len(batch)*n)
for _, s := range batch {
vals, ok := s.Values[sig.Name]
if !ok || len(vals) < n {
continue
}
// wallT ≈ arrival time of the packet ≈ timestamp of the last sample.
wallT := float64(s.WallTime.UnixNano()) / 1e9
// Compute the anchor timestamp for this burst.
// Prefer the embedded time-signal value (microseconds) so that
// consecutive bursts are perfectly contiguous regardless of jitter.
var anchorTime float64
anchorIsFirstSample := (sig.TimeMode == TimeModeFirstSample)
if hasTimeSig {
tVals, tOk := s.Values[timeSigName]
if tOk && len(tVals) >= 1 {
// Time signal is uint32 microseconds from system start.
timerS := tVals[0] * 1e-6
wallT := float64(s.WallTime.UnixNano()) / 1e9
// Calibrate the wall-clock offset once per session so that
// anchor times can be expressed as absolute Unix timestamps.
if _, exists := h.timeSigCalib[timeSigName]; !exists {
h.timeSigCalib[timeSigName] = wallT - timerS
}
anchorTime = h.timeSigCalib[timeSigName] + timerS
} else {
// Time signal missing in this packet fall back to wall clock.
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
anchorIsFirstSample = false // wallT = last sample
}
} else {
// No time signal configured use wall arrival as last-sample anchor.
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
anchorIsFirstSample = false
}
for k := 0; k < n; k++ {
allT = append(allT, wallT-float64(n-1-k)*dt)
var t float64
if anchorIsFirstSample {
t = anchorTime + float64(k)*dt
} else {
t = anchorTime - float64(n-1-k)*dt
}
allT = append(allT, t)
allV = append(allV, vals[k])
}
}