WebUI: per-signal vscale toolbar, active-signal highlighting, zoom fix
- Per-signal, per-plot vertical scale state (sigVScale keyed by plotId:signalKey) so the same signal in two plots has fully independent vscale config - Active signal redrawn on top of all series with 2× line width for clear visual identification; badge click toggles selection and opens/closes the embedded vscale toolbar (click same badge again to deselect) - Vscale configurator moved from floating popup to a slim toolbar strip anchored inside the plot card, with an × close button - Trigger dropdown shows one entry per array signal with [0…N-1] label; opening it shows an index-picker dialog to choose the element - Zoom resampling: when server returns no data for a zoomed range, fall back to the local circular buffer instead of returning empty arrays Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+116
-37
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user