From e03c60db251a85e7e9655c09ba87943eb522c759 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 21 Aug 2026 23:24:48 +0200 Subject: [PATCH] Implemented and fixed many issues --- AGENTS.md | 9 +- ARCHITECTURE.md | 42 +- Client/streamhub-qt/Hub.cpp | 10 +- Client/streamhub-qt/Model.h | 5 + Client/streamhub-qt/PlotWidget.cpp | 310 +++- Client/streamhub-qt/PlotWidget.h | 6 +- Client/streamhub/App.cpp | 6 + Client/streamhub/App.h | 13 +- Client/streamhub/PlotPanel.cpp | 253 +++- Client/streamhub/Protocol.cpp | 6 + Client/streamhub/Protocol.h | 5 + Client/udpstreamer/main.go | 59 +- Client/udpstreamer/static/app.js | 1056 +++++++++---- Client/udpstreamer/static/decimate-worker.js | 51 + Client/udpstreamer/static/index.html | 67 +- Client/udpstreamer/static/lttb-worker.js | 39 - Client/udpstreamer/static/style.css | 54 +- .../Client/go/wshub/capture_coverage_test.go | 168 +++ Common/Client/go/wshub/capturehold.go | 83 + Common/Client/go/wshub/capturehold_test.go | 128 ++ Common/Client/go/wshub/history.go | 1339 +++++++++++++++++ Common/Client/go/wshub/history_test.go | 949 ++++++++++++ Common/Client/go/wshub/hub.go | 307 +++- .../Client/go/wshub/hub_calibration_test.go | 2 +- .../Client/go/wshub/multicast_iface_test.go | 116 ++ Common/Client/go/wshub/ringbuf.go | 326 +++- Common/Client/go/wshub/ringbuf_test.go | 147 ++ Common/Client/go/wshub/sources.go | 63 +- Common/Client/go/wshub/stats.go | 5 +- Common/Client/go/wshub/trigger.go | 364 ++++- .../Client/go/wshub/trigger_capture_test.go | 278 ++++ Common/Client/go/wshub/trigger_test.go | 335 ++++- Common/Client/go/wshub/zoom_test.go | 61 +- Docs/StreamHub-API.md | 64 +- Docs/StreamHub-Developer.md | 263 +++- README.md | 17 +- .../Applications/StreamHub/SignalRingBuffer.h | 96 ++ Source/Applications/StreamHub/StreamHub.cpp | 395 +++-- Source/Applications/StreamHub/StreamHub.h | 56 +- .../Applications/StreamHub/TriggerEngine.cpp | 9 +- Source/Applications/StreamHub/TriggerEngine.h | 6 +- .../StreamHub/UDPSourceSession.cpp | 77 + .../Applications/StreamHub/UDPSourceSession.h | 65 +- Source/Applications/StreamHub/WSServer.cpp | 97 +- Source/Applications/StreamHub/WSServer.h | 26 +- .../GAMs/TimeArrayGAM/TimeArrayGAM.cpp | 25 +- .../GAMs/TimeArrayGAM/TimeArrayGAM.h | 15 +- .../StreamHub/TriggerEngineGTest.cpp | 2 +- Test/Configurations/streamhub_demo.cfg | 4 +- run_debugtest.sh | 285 ++++ run_streamhub.sh | 3 + run_udp_producer.sh | 328 ++++ 52 files changed, 7726 insertions(+), 769 deletions(-) create mode 100644 Client/udpstreamer/static/decimate-worker.js delete mode 100644 Client/udpstreamer/static/lttb-worker.js create mode 100644 Common/Client/go/wshub/capture_coverage_test.go create mode 100644 Common/Client/go/wshub/capturehold.go create mode 100644 Common/Client/go/wshub/capturehold_test.go create mode 100644 Common/Client/go/wshub/history.go create mode 100644 Common/Client/go/wshub/history_test.go create mode 100644 Common/Client/go/wshub/multicast_iface_test.go create mode 100644 Common/Client/go/wshub/ringbuf_test.go create mode 100644 Common/Client/go/wshub/trigger_capture_test.go create mode 100755 run_debugtest.sh create mode 100755 run_udp_producer.sh diff --git a/AGENTS.md b/AGENTS.md index 55df71e..9d626ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ UDP 8081 telemetry, TcpLogger 8082 (REPORT_ERROR → "LOG " lines) |---|---| | `Source/Components/DataSources/UDPStreamer/` | Output DataSource; UDP I/O on bg thread, RT thread only spinlock+memcpy in `Synchronise()` | | `Source/Components/DataSources/UDPStreamerClient/` | Input DataSource (shared `UDPSClient`), double-buffered ready/scratch | -| `Source/Components/GAMs/` | `SineArrayGAM` (float32 sine, continuous phase), `TimeArrayGAM` (us-timer → per-sample timestamp array) | +| `Source/Components/GAMs/` | `SineArrayGAM` (float32 sine, continuous phase), `TimeArrayGAM` (us-timer → per-sample timestamp array; `Anchor = FirstSample|LastSample|Continuous`, use `Continuous` for contiguous sources so a lost RT cycle cannot hole the time base) | | `Source/Components/Interfaces/DebugService/` | Registry patching, `DebugBrokerWrapper.h`, TCP/UDP services | | `Source/Components/Interfaces/TCPLogger/` | `LoggerConsumerI` forwarding `REPORT_ERROR` to ≤8 TCP clients | | `Source/Components/Interfaces/UDPStream/` | Plain-C++ helpers (not MARTe2 Objects): `UDPSClient` (auto-reconnect + fragment reassembly), `UDPSServer` (not thread-safe — owner's Execute thread only) | @@ -140,8 +140,11 @@ cd Client/streamhub-qt && cmake -B build && cmake --build build with long options: `--host HOST --port 8090` (single-dash misparsed). Single GUI thread, 60 Hz QTimer repaint. - **StreamHub config** is *not* a MARTe2 `RealTimeApplication`: `Hub = { WSPort - MaxPoints PushRate MaxPushPoints RingTemporal RingScalar +Recorder{...} - Sources={id={Label Addr Port}} }`. `+History` keys: `Directory` (required), + MaxPoints PushRate MaxPushPoints RingTemporal RingScalar RingMaxMB AllowedOrigins + +Recorder{...} Sources={id={Label Addr Port}} }`. `AllowedOrigins` is the + WebSocket Origin allowlist — without it a browser serving the SPA from a + different port than the hub is rejected 403. + `+History` keys: `Directory` (required), `DurationHours` (1), `Decimation` (1), `FlushIntervalSec` (5), `MinDiskFreeMB` (500). `.shist` files: 64-byte header ('SHR1') + circular (t,v) float64 pairs. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0ae5661..cb4557e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -314,7 +314,7 @@ Hub-side trigger with the web client's semantics (config: signal key ``` IDLE →[arm]→ ARMED ARMED →[edge crossing]→ COLLECTING (latches trigTime, pre/postSec) -COLLECTING →[post window + margin elapsed]→ TRIGGERED (broadcast binary v2 capture) +COLLECTING →[every source produced past the window]→ TRIGGERED (broadcast binary v2 capture) TRIGGERED →[auto-rearm (normal, ~200 ms) | rearm (single)]→ ARMED any →[disarm]→ IDLE ``` @@ -325,6 +325,30 @@ sample of the configured signal. The capture is assembled in the push loop from LTTB-capped at 20 000 points/signal, and broadcast as a binary version-2 frame. A `stopped` flag (`trigStop`) freezes auto-rearm. +COLLECTING is left on the **data's** clock, not `clock_gettime()`: `trigTime` +comes from sample timestamps, and a source that free-runs on its own clock sits +seconds away from wall time, so a wall-clock deadline chops exactly that offset +off every capture's tail. `UDPSourceSession::ProducerNewestTime()` reports how +far a source has produced — counting only signals actually timestamped from a +time signal, since PACKET-timed ones (the time array itself included) are +stamped on arrival and would just report "now". + +Sources are harvested **one at a time**, each as soon as *it* passes +`trigTime + postSec + 0.15 s` (`BeginTriggerCapture` / `HarvestTriggerCapture` / +`FinishTriggerCapture`, the frame accumulating across push ticks). Making every +source wait for the slowest lets the leaders' rings roll past the pre-trigger +region before it is ever read. A 2 s wall-clock watchdog per capture bounds the +wait for a source that stopped advancing; it is harvested short, with a warning +naming the source and how far it got. + +Because `RingTemporal` only holds ~1 s at 1 MSps, `setTrigger` publishes the +requested window and the push loop calls `GrowRingsForTrigger()`: each ring +measures its own rate (`Count() / TimeSpan()` — UDPS sources usually report +`samplingRate = 0`) and is grown in place to `rate × (window + 0.5 s) × 1.2`, +clamped per signal to `RingMaxMB`. `SignalRingBuffer::Grow()` preserves +contents *and* `totalWritten`, so live push cursors stay valid. Without this a +long window only ever captures its tail. + ### Configuration File (MARTe2 cfg format) ``` @@ -334,9 +358,11 @@ Hub = { PushRate = 30 // push loop Hz MaxPushPoints = 50 // LTTB cap per signal per tick StatsRate = 1 // stats broadcast Hz - RingTemporal = 1000000 // ring capacity (points) for multi-element signals + RingTemporal = 1000000 // initial ring capacity (points) for multi-element signals RingScalar = 100000 // ring capacity (points) for scalar signals + RingMaxMB = 128 // per-signal ceiling when a trigger window grows a ring SourcesFile = "streamhub_sources.json" // dynamic-source persistence + AllowedOrigins = "http://127.0.0.1:8099,http://localhost:8099" // see below Sources = { App1 = { Label = "MARTe2 App 1" @@ -358,6 +384,16 @@ Sources added at runtime (`addSource`) get generated ids `s1, s2, …`; `saveSources` persists them to `SourcesFile` (JSON array of `{label, addr, multicastGroup?, dataPort?}`), reloaded at start-up. +`AllowedOrigins` is a comma/space-separated allowlist of `scheme://host[:port]` +values accepted in the WebSocket `Origin` header (max 8 entries, 128 chars +each), matching the Go hub's option. Without it the handshake only accepts an +`Origin` whose host matches the request `Host` — so a browser that loaded the +SPA from a *different* port than the hub (the `run_streamhub.sh` layout, SPA on +8099 and hub on 8090) is rejected with 403. Non-browser clients send no `Origin` +and are unaffected. This is the CSWSH guard of RFC 6455 §10.2: browsers attach +cookies to cross-origin WebSocket handshakes, so `Origin` is the only thing +distinguishing a legitimate page from an attacker's. + ### Build ```bash @@ -401,7 +437,7 @@ binary frames carry data push payloads. | `sources` | `sources:[{id, label, addr:"host:port", state}]` | On connect; after add/remove/getSources; on first CONFIG | | `config` | `sourceId`, `publishMode`, `signals:[{name, typeCode, quantType, numDimensions, numRows, numCols, rangeMin, rangeMax, timeMode, samplingRate, timeSignalIdx, unit}]` | After CONFIG received from source | | `stats` | `sources:{id:{state, totalReceived, totalLost, rateHz, rateStdHz, fragsPerCycle, bytesPerCycle, cycleAvgMs, cycleStdMs, cycleMinMs, cycleMaxMs, cycleHistMin, cycleHistMax, cycleHist:[20]}}` | At `StatsRate` Hz | -| `triggerState` | `state` (`"idle"`\|`"armed"`\|`"collecting"`\|`"triggered"`), `mode`, `stopped`, `trigTime?` | On any trigger FSM transition | +| `triggerState` | `state` (`"idle"`\|`"armed"`\|`"collecting"`\|`"triggered"`), `mode`, `stopped`, `trigTime?`, `preSec?`, `postSec?` | On any trigger FSM transition | | `zoom` | `reqId`, `signals:{"src:sig":{t:[…], v:[…]}}` (`t` printed `%.17g`, `v` `%.9g`) | Unicast reply to `zoom` | | `maxPointsUpdated` | `maxPoints` | After ring buffer resize | | `calibration` | `cal:[{source, signal, scale, offset, unit}]` | On connect; after an accepted `setCalibration`; after a successful `reloadConfig` | diff --git a/Client/streamhub-qt/Hub.cpp b/Client/streamhub-qt/Hub.cpp index 8cbbe6f..e5a388b 100644 --- a/Client/streamhub-qt/Hub.cpp +++ b/Client/streamhub-qt/Hub.cpp @@ -160,7 +160,15 @@ void Hub::onTriggerState(const std::string& json) { trigger_.trigTime = msg.trigTime; trigger_.hasTrigTime = true; } - if (msg.state == "idle") { trigger_.hasTrigTime = false; } + if (msg.hasWindow) { + trigger_.firedPreS = msg.preSec; + trigger_.firedPostS = msg.postSec; + trigger_.hasFiredWin = true; + } + if (msg.state == "idle") { + trigger_.hasTrigTime = false; + trigger_.hasFiredWin = false; + } Q_EMIT triggerStateChanged(); } diff --git a/Client/streamhub-qt/Model.h b/Client/streamhub-qt/Model.h index 3760a7d..b72cbd6 100644 --- a/Client/streamhub-qt/Model.h +++ b/Client/streamhub-qt/Model.h @@ -60,6 +60,11 @@ struct TriggerCfgState { bool stopped = false; bool hasTrigTime = false; double trigTime = 0.0; + /* Window the hub latched at fire time. Not the same as windowSec/prePercent + * above, which are editable and may have moved on since the trigger fired. */ + bool hasFiredWin = false; + double firedPreS = 0.0; + double firedPostS = 0.0; }; /** Per-signal vertical scale state (oscilloscope style). */ diff --git a/Client/streamhub-qt/PlotWidget.cpp b/Client/streamhub-qt/PlotWidget.cpp index 1ee08f5..e2c7fcd 100644 --- a/Client/streamhub-qt/PlotWidget.cpp +++ b/Client/streamhub-qt/PlotWidget.cpp @@ -78,6 +78,49 @@ static double normalizeY(double raw, const VScale& vs) { return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos; } +/* Resolve the one scale every trace shares in unified mode: same rules as the + * per-signal version applied to the union of the plot — range takes the union + * of the declared ranges, auto fits the union of the data. */ +static void resolveUnifiedVScale(VScale& vs, + const std::vector& slots, + const std::vector& sources, + const std::vector >& vStore) { + if (vs.mode == 2) { + vs.resolvedDiv = std::max(vs.divValue, 1e-30); + vs.resolvedOffset = vs.offset; + return; + } + double mn = 1e300, mx = -1e300; + if (vs.mode == 1) { + for (const auto& a : slots) { + if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue; + if (a.signalIdx < 0 || + a.signalIdx >= (int)sources[a.sourceIdx].signals.size()) continue; + const auto& m = sources[a.sourceIdx].signals[a.signalIdx].meta; + if (!(m.rangeMax > m.rangeMin)) continue; + if (m.rangeMin < mn) mn = m.rangeMin; + if (m.rangeMax > mx) mx = m.rangeMax; + } + if (mx > mn) { + vs.resolvedDiv = std::max((mx - mn) / 8.0, 1e-30); + vs.resolvedOffset = (mn + mx) / 2.0; + return; + } + mn = 1e300; mx = -1e300; /* no usable range: fall through to auto */ + } + for (const auto& vv : vStore) { + for (double v : vv) { + if (!std::isfinite(v)) continue; + if (v < mn) mn = v; + if (v > mx) mx = v; + } + } + if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; } + if (mn == mx) { mn -= 1.0; mx += 1.0; } + vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30); + vs.resolvedOffset = (mx + mn) / 2.0; +} + static bool dataMinMax(const std::vector& v, double& mn, double& mx) { mn = 1e300; mx = -1e300; for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } } @@ -189,6 +232,50 @@ void PlotCanvas::drawMarker(QPainter& p, double cx, double cy, int marker, doubl } } +/** @brief What the plot renders on the trigger-relative axis, if anything. */ +struct TrigView { + bool rel = false; /* render against t - trig instead of wall clock */ + bool fromCap = false; /* data comes from the capture frame, not the ring */ + double trigT = 0.0; + double preS = 0.0; + double postS = 0.0; +}; + +/* Two ways to end up in trigger-relative time. Either a v2 capture frame has + * arrived, or a trigger has fired and its window is still filling. In the + * second case the hub sends nothing until the whole window has been produced — + * several seconds for a long window at a high rate — so the trace is drawn from + * the local rings onto the final axis, growing left to right. Filling wins + * over the last capture: once a new trigger fires the old waveform is history. + * A capture latches its own pre/post at fire time, so later edits in the + * trigger bar must not move the axis of a finished capture. */ +static TrigView resolveTrigView(Hub* hub, const GlobalView* gv, bool paused) { + TrigView tv; + if (!gv->trigView) { return tv; } + + const TriggerCfgState& t = hub->trigger(); + if (!paused && t.status == "collecting" && t.hasTrigTime) { + tv.rel = true; + tv.trigT = t.trigTime; + /* Prefer the window the hub latched at fire time; the local config is + * only a fallback for hubs that do not report it, and may have been + * edited since the trigger fired. */ + tv.preS = t.hasFiredWin ? t.firedPreS + : t.windowSec * t.prePercent * 0.01; + tv.postS = t.hasFiredWin ? t.firedPostS : t.windowSec - tv.preS; + return tv; + } + const CaptureFrame* cap = hub->capture(); + if (cap != nullptr) { + tv.rel = true; + tv.fromCap = true; + tv.trigT = cap->trigTime; + tv.preS = cap->preSec; + tv.postS = cap->postSec; + } + return tv; +} + void PlotCanvas::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); @@ -205,13 +292,14 @@ void PlotCanvas::paintEvent(QPaintEvent*) { p.fillRect(rect(), col::base()); p.fillRect(r, col::crust()); - const CaptureFrame* cap = hub->capture(); - const bool trigView = (cap != nullptr) && gv->trigView; auto& zc = hub->zoomCache(w_->plotIdx_); auto& hc = hub->histZoomCache(w_->plotIdx_); const bool paused = w_->paused_; bool& live = w_->live_; + const TrigView tv = resolveTrigView(hub, gv, paused); + const CaptureFrame* cap = hub->capture(); + /* ── pause snapshot ─────────────────────────────────────────────────── */ auto& snap = w_->snap_; if (paused) { @@ -239,15 +327,15 @@ void PlotCanvas::paintEvent(QPaintEvent*) { /* ── gather data per slot ───────────────────────────────────────────── */ std::vector> tStore(slots.size()), vStore(slots.size()); - const bool liveHiRes = !trigView && live && !paused && + const bool liveHiRes = !tv.rel && live && !paused && gv->windowSec <= kLiveHiResMaxWin && zc.valid && (zc.t1 - zc.t0) >= gv->windowSec * 0.9 && (wallNow - zc.t1) < 3.0; - const bool useZoomData = !trigView && !paused && zc.valid && + const bool useZoomData = !tv.rel && !paused && zc.valid && (liveHiRes || (!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9)); - bool useHistData = !trigView && !paused && !live && hc.valid && + bool useHistData = !tv.rel && !paused && !live && hc.valid && hc.t0 <= w_->plotXMin_ + 1e-9 && hc.t1 >= w_->plotXMax_ - 1e-9; if (useHistData) { bool any = false; @@ -271,17 +359,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) { const auto& sig = sources[a.sourceIdx].signals[a.signalIdx]; const std::string key = hub->slotKey(a); - if (trigView) { + if (tv.fromCap) { for (const auto& cs : cap->signals) { if (cs.key != key) continue; size_t n = std::min(cs.t.size(), cs.v.size()); tStore[si].reserve(n); vStore[si].reserve(n); for (size_t i = 0; i < n; i++) { - tStore[si].push_back(cs.t[i] - cap->trigTime); + tStore[si].push_back(cs.t[i] - tv.trigT); vStore[si].push_back(cs.v[i]); } break; } + } else if (tv.rel) { + /* Filling: local ring, clipped to the (absolute) trigger window and + * shifted onto the trigger-relative axis. */ + sig.buf.readRange(tv.trigT - tv.preS, tv.trigT + tv.postS, + tStore[si], vStore[si]); + for (size_t i = 0; i < tStore[si].size(); i++) { + tStore[si][i] -= tv.trigT; + } } else if (useZoomData) { bool found = false; for (const auto& zs : zc.pts) { @@ -302,11 +398,18 @@ void PlotCanvas::paintEvent(QPaintEvent*) { resolveVScale(a, sig, vStore[si]); } + if (w_->vMode_ == 3) { + resolveUnifiedVScale(w_->uniVS_, slots, sources, vStore); + } + /* ── X range ────────────────────────────────────────────────────────── */ double xMin, xMax; - if (trigView) { + if (tv.rel) { if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; } - else { xMin = -cap->preSec; xMax = cap->postSec; } + /* Full window from the start, even while filling: a trace growing into + * a fixed axis reads as progress, whereas an axis that grows with the + * data shifts the whole trace every frame. */ + else { xMin = -tv.preS; xMax = tv.postS; } } else if (live && !paused) { if (liveHiRes) { xMax = zc.t1; xMin = zc.t1 - gv->windowSec; } else { xMax = wallNow; xMin = wallNow - gv->windowSec; } @@ -319,19 +422,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) { /* ── grid + ticks ───────────────────────────────────────────────────── */ p.setPen(QPen(QColor(0x31,0x32,0x44,160), 1.0)); /* Y grid: 9 division lines */ - const auto& av = (w_->vMode_ == 0 && w_->activeSlot_ >= 0 && - w_->activeSlot_ < (int)slots.size()) - ? slots[w_->activeSlot_].vs : VScale(); + /* Which scale labels the axis: the active signal's in normal mode, the one + * the whole plot shares in unified mode (where nothing has to be selected). + * Banded modes have no single scale, so they keep the plain division numbers. */ + const VScale* axisVS = nullptr; + if (w_->vMode_ == 0 && w_->activeSlot_ >= 0 && + w_->activeSlot_ < (int)slots.size()) { + axisVS = &slots[w_->activeSlot_].vs; + } else if (w_->vMode_ == 3) { + axisVS = &w_->uniVS_; + } p.setFont(QFont(font().family(), 8)); for (int d = -4; d <= 4; d++) { double y = yToPx(d, r); p.setPen(QPen(QColor(0x31,0x32,0x44, d==0?220:120), d==0?1.2:1.0)); p.drawLine(QPointF(r.left(), y), QPointF(r.right(), y)); QString lbl; - if (w_->vMode_ == 0 && w_->activeSlot_ >= 0 && - w_->activeSlot_ < (int)slots.size()) { - double rawVal = av.resolvedOffset + (d - av.screenPos) * av.resolvedDiv; - lbl = fmtVal(rawVal); + if (axisVS != nullptr) { + lbl = fmtVal(axisVS->resolvedOffset + + (d - axisVS->screenPos) * axisVS->resolvedDiv); } else { lbl = QString::number(d); } @@ -346,7 +455,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) { p.setPen(QPen(QColor(0x31,0x32,0x44,120), 1.0)); p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom())); p.setPen(QColor(0xa6,0xad,0xc8)); - QString xl = trigView ? fmtVal(xv) + "s" : QString::number(xv, 'f', 3); + QString xl = tv.rel ? fmtVal(xv) + "s" : QString::number(xv, 'f', 3); int flags = (t==0?Qt::AlignLeft:(t==10?Qt::AlignRight:Qt::AlignHCenter)) | Qt::AlignTop; p.drawText(QRectF(x-40, r.bottom()+2, 80, 14), flags, xl); @@ -396,8 +505,10 @@ void PlotCanvas::paintEvent(QPaintEvent*) { if (w_->vMode_ == 1) bandNormalize(vDec, vNorm, myKi, nTraces, true); else if (w_->vMode_ == 2) bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed); else { + /* unified shares one scale, normal gives each trace its own */ + const VScale& nvs = (w_->vMode_ == 3) ? w_->uniVS_ : a.vs; vNorm.resize(nOut); - for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], a.vs); + for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], nvs); } QColor c = sig.color; @@ -423,7 +534,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) { } /* trigger instant marker at t=0 */ - if (trigView) { + if (tv.rel) { double x = xToPx(0.0, xMin, xMax, r); p.setPen(QPen(QColor(255,255,0,200), 1.5, Qt::DashLine)); p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom())); @@ -476,8 +587,7 @@ void PlotCanvas::wheelEvent(QWheelEvent* e) { Hub* hub = w_->hub_; GlobalView* gv = w_->gv_; auto& slots = w_->slots_; - const CaptureFrame* cap = hub->capture(); - const bool trigView = (cap != nullptr) && gv->trigView; + const TrigView tv = resolveTrigView(hub, gv, w_->paused_); bool& live = w_->live_; double dy = e->angleDelta().y(); @@ -488,43 +598,51 @@ void PlotCanvas::wheelEvent(QWheelEvent* e) { const double now = nowSec(); auto enterTrigZoom = [&]() { - if (trigView && !w_->trigZoomed_) { - w_->setStoredX(-cap->preSec, cap->postSec); + if (tv.rel && !w_->trigZoomed_) { + w_->setStoredX(-tv.preS, tv.postS); w_->trigZoomed_ = true; } }; auto xZoomStored = [&](double f) { - if (trigView) enterTrigZoom(); + if (tv.rel) enterTrigZoom(); if (now - w_->lastHistPushMs_ > 0.6) { w_->pushZoomHist(); w_->lastHistPushMs_ = now; } double cx = (w_->plotXMin_ + w_->plotXMax_) * 0.5; double half = (w_->plotXMax_ - w_->plotXMin_) * 0.5 * f; w_->setStoredX(cx - half, cx + half); }; - auto makeManual = [&](PlotAssignment& a) { - if (a.vs.mode != 2) { - a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30); - a.vs.offset = a.vs.resolvedOffset; - a.vs.mode = 2; + /* Seed manual from the resolved values so the gesture sticks. */ + auto makeManual = [&](VScale& vs) { + if (vs.mode != 2) { + vs.divValue = std::max(vs.resolvedDiv, 1e-30); + vs.offset = vs.resolvedOffset; + vs.mode = 2; } }; + /* Scroll adjusts the scale the axis is labelled with: the active signal's in + * normal mode, the plot's shared one in unified mode (nothing to select). */ + VScale* wheelVS = nullptr; + if (w_->vMode_ == 3) { + wheelVS = &w_->uniVS_; + } else if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) { + wheelVS = &slots[w_->activeSlot_].vs; + } + if (ctrl) { - if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); + if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); else xZoomStored(factor); } else if (shift) { - if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) { - auto& a = slots[w_->activeSlot_]; - makeManual(a); - a.vs.screenPos += (dy > 0) ? 0.5 : -0.5; + if (wheelVS != nullptr) { + makeManual(*wheelVS); + wheelVS->screenPos += (dy > 0) ? 0.5 : -0.5; } } else { - if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) { - auto& a = slots[w_->activeSlot_]; - makeManual(a); - a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30); + if (wheelVS != nullptr) { + makeManual(*wheelVS); + wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30); } else { - if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); + if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); else xZoomStored(factor); } } @@ -549,8 +667,7 @@ void PlotCanvas::mouseMoveEvent(QMouseEvent* e) { GlobalView* gv = w_->gv_; Hub* hub = w_->hub_; const QRectF r = plotRect(); - const CaptureFrame* cap = hub->capture(); - const bool trigView = (cap != nullptr) && gv->trigView; + const TrigView tv = resolveTrigView(hub, gv, w_->paused_); bool& live = w_->live_; if (dragCursor_ != 0) { @@ -560,11 +677,11 @@ void PlotCanvas::mouseMoveEvent(QMouseEvent* e) { return; } if (panning_) { - if (trigView && !w_->trigZoomed_) { - w_->setStoredX(-cap->preSec, cap->postSec); + if (tv.rel && !w_->trigZoomed_) { + w_->setStoredX(-tv.preS, tv.postS); w_->trigZoomed_ = true; } - if (!trigView && live) { w_->initPlotX(nowSec()); live = false; } + if (!tv.rel && live) { w_->initPlotX(nowSec()); live = false; } double dxPix = e->pos().x() - lastPos_.x(); lastPos_ = e->pos(); double xRange = w_->plotXMax_ - w_->plotXMin_; @@ -677,11 +794,10 @@ void PlotWidget::onCaptureReceived() { void PlotWidget::tick() { Hub* hub = hub_; GlobalView* gv = gv_; - const CaptureFrame* cap = hub->capture(); - const bool trigView = (cap != nullptr) && gv->trigView; + const TrigView tv = resolveTrigView(hub, gv, paused_); const double now = nowSec(); - if (!trigView && !paused_) { + if (!tv.rel && !paused_) { std::string csv; for (const auto& a : slots_) { std::string k = hub->slotKey(a); @@ -736,9 +852,13 @@ void PlotWidget::rebuildHeader() { auto* b = new QToolButton(header_); b->setCheckable(true); b->setChecked(activeSlot_ == i); - b->setText(QString("%1 %2/div") - .arg(QString::fromStdString(sig.meta.name)) - .arg(fmtVal(a.vs.resolvedDiv))); + /* In unified mode every badge would repeat the same div value, which + * the header's Y-Scale button already shows — so show just the name. */ + b->setText(vMode_ == 3 + ? QString::fromStdString(sig.meta.name) + : QString("%1 %2/div") + .arg(QString::fromStdString(sig.meta.name)) + .arg(fmtVal(a.vs.resolvedDiv))); QColor c = sig.color; QString fg = (activeSlot_ == i) ? "#11111b" : "#11111b"; QColor bg = (activeSlot_ == i) ? col::blue() : c; @@ -797,11 +917,17 @@ void PlotWidget::rebuildHeader() { headerLay_->addWidget(fit); } - /* N / D / M */ - const char* vl[3] = {"N", "D", "M"}; - for (int vm = 0; vm < 3; vm++) { + /* N / U / D / M */ + const char* vl[4] = {"N", "U", "D", "M"}; + const char* vtip[4] = {"Normal: one vertical scale per signal", + "Unified: one vertical scale shared by every signal", + "Digital", "Mixed"}; + const int vmode[4] = {0, 3, 1, 2}; + for (int i = 0; i < 4; i++) { + const int vm = vmode[i]; auto* vb = new QToolButton(header_); - vb->setText(vl[vm]); + vb->setText(vl[i]); + vb->setToolTip(vtip[i]); vb->setCheckable(true); vb->setChecked(vMode_ == vm); connect(vb, &QToolButton::clicked, this, [this, vm]() { @@ -810,9 +936,57 @@ void PlotWidget::rebuildHeader() { headerLay_->addWidget(vb); } + /* Unified mode's single scale belongs to the plot, not to any one signal, + * so it is edited from here rather than from a badge's context menu. */ + if (vMode_ == 3) { + auto* yb = new QToolButton(header_); + yb->setText(QString("Y-Scale: %1/div").arg(fmtVal(uniVS_.resolvedDiv))); + yb->setToolTip("Vertical scale shared by every signal in this plot"); + connect(yb, &QToolButton::clicked, this, [this, yb]() { + showUnifiedVScaleMenu(yb->mapToGlobal(QPoint(0, yb->height()))); + }); + headerLay_->addWidget(yb); + } + headerLay_->addStretch(1); } +/** Populate @a vs with the Auto/Range/Manual entries driving @a evs. */ +void PlotWidget::buildVScaleMenu(QMenu* vs, VScale& evs) { + const char* modes[] = {"Auto", "Range", "Manual"}; + for (int mm = 0; mm < 3; mm++) { + QAction* act = vs->addAction(modes[mm]); + act->setCheckable(true); act->setChecked(evs.mode == mm); + connect(act, &QAction::triggered, this, [this, &evs, mm]() { + evs.mode = mm; rebuildHeader(); canvas_->update(); + }); + } + vs->addSeparator(); + vs->addAction("Manual V/div…", [this, &evs]() { + bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division", + evs.mode==2?evs.divValue:evs.resolvedDiv, -1e12, 1e12, 6, &ok); + if (ok) { evs.divValue = v; evs.mode = 2; rebuildHeader(); canvas_->update(); } + }); + vs->addAction("Offset…", [this, &evs]() { + bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value", + evs.mode==2?evs.offset:evs.resolvedOffset, -1e12, 1e12, 6, &ok); + if (ok) { evs.offset = v; evs.mode = 2; rebuildHeader(); canvas_->update(); } + }); + vs->addAction("Position (div)…", [this, &evs]() { + bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center", + evs.screenPos, -8, 8, 2, &ok); + if (ok) { evs.screenPos = v; canvas_->update(); } + }); +} + +void PlotWidget::showUnifiedVScaleMenu(const QPoint& globalPos) { + QMenu m; + m.addAction("Y-Scale — all signals")->setEnabled(false); + m.addSeparator(); + buildVScaleMenu(&m, uniVS_); + m.exec(globalPos); +} + void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) { auto& sources = hub_->sources(); if (slotIdx < 0 || slotIdx >= (int)slots_.size()) return; @@ -846,30 +1020,12 @@ void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) { connect(dg, &QAction::toggled, this, [&](bool on){ a.vs.digitalInMixed = on; canvas_->update(); }); } - m.addSeparator(); - QMenu* vs = m.addMenu("V-scale"); - const char* modes[] = {"Auto", "Range", "Manual"}; - for (int mm = 0; mm < 3; mm++) { - QAction* act = vs->addAction(modes[mm]); - act->setCheckable(true); act->setChecked(a.vs.mode == mm); - connect(act, &QAction::triggered, this, [&, mm]() { a.vs.mode = mm; rebuildHeader(); canvas_->update(); }); + /* In unified mode the plot has one scale for every trace, so it is edited + * from the header's Y-Scale button instead of from any one signal. */ + if (vMode_ != 3) { + m.addSeparator(); + buildVScaleMenu(m.addMenu("V-scale"), a.vs); } - vs->addSeparator(); - vs->addAction("Manual V/div…", [&]() { - bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division", - a.vs.mode==2?a.vs.divValue:a.vs.resolvedDiv, -1e12, 1e12, 6, &ok); - if (ok) { a.vs.divValue = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); } - }); - vs->addAction("Offset…", [&]() { - bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value", - a.vs.mode==2?a.vs.offset:a.vs.resolvedOffset, -1e12, 1e12, 6, &ok); - if (ok) { a.vs.offset = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); } - }); - vs->addAction("Position (div)…", [&]() { - bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center", - a.vs.screenPos, -8, 8, 2, &ok); - if (ok) { a.vs.screenPos = v; canvas_->update(); } - }); m.addSeparator(); m.addAction("Remove from plot", [&]() { diff --git a/Client/streamhub-qt/PlotWidget.h b/Client/streamhub-qt/PlotWidget.h index bb2afc0..7943809 100644 --- a/Client/streamhub-qt/PlotWidget.h +++ b/Client/streamhub-qt/PlotWidget.h @@ -21,6 +21,7 @@ class QHBoxLayout; class QToolButton; class QLabel; +class QMenu; namespace shq { @@ -69,6 +70,8 @@ private: friend class PlotCanvas; void rebuildHeader(); + void buildVScaleMenu(QMenu* vs, VScale& evs); + void showUnifiedVScaleMenu(const QPoint& globalPos); void showBadgeMenu(int slotIdx, const QPoint& globalPos); void pushZoomHist(); void initPlotX(double tMax); @@ -87,7 +90,8 @@ private: bool paused_ = false; double plotXMin_ = 0.0; double plotXMax_ = 0.0; - int vMode_ = 0; /* 0 normal 1 digital 2 mixed */ + int vMode_ = 0; /* 0 normal 1 digital 2 mixed 3 unified */ + VScale uniVS_; /* the one scale every trace shares in mode 3 */ int activeSlot_ = -1; bool trigZoomed_ = false; diff --git a/Client/streamhub/App.cpp b/Client/streamhub/App.cpp index fe5fe56..1beebef 100644 --- a/Client/streamhub/App.cpp +++ b/Client/streamhub/App.cpp @@ -825,11 +825,17 @@ void App::onTriggerState(const std::string& json) { trigger_.trigTime = msg.trigTime; trigger_.hasTrigTime = true; } + if (msg.hasWindow) { + trigger_.firedPreS = msg.preSec; + trigger_.firedPostS = msg.postSec; + trigger_.hasFiredWin = true; + } /* Double-buffer semantics: the last recorded capture stays on display * (even while re-armed/collecting) and is only replaced when a new * capture frame has been fully received and parsed (handleBinary v2). */ if (msg.state == "idle") { trigger_.hasTrigTime = false; + trigger_.hasFiredWin = false; } } diff --git a/Client/streamhub/App.h b/Client/streamhub/App.h index b4902e2..de9b710 100644 --- a/Client/streamhub/App.h +++ b/Client/streamhub/App.h @@ -61,6 +61,11 @@ struct TriggerState { bool stopped = false; bool hasTrigTime = false; double trigTime = 0.0; + /* Window the hub latched at fire time. Not the same as windowSec/prePercent + * above, which are editable and may have moved on since the trigger fired. */ + bool hasFiredWin = false; + double firedPreS = 0.0; + double firedPostS = 0.0; }; /** Per-signal vertical scale state (oscilloscope style). */ @@ -183,9 +188,12 @@ public: plotXMax_[i] = tMax; } - /** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed. */ + /** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed 3=unified. */ int& plotVMode(int i) { return plotVMode_[i]; } + /** @brief The one scale every trace shares in unified mode (vMode 3). */ + VScale& plotUnifiedVS(int i) { return plotUniVS_[i]; } + /* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */ bool& cursorsOn() { return cursorsOn_; } double& cursorA() { return cursorA_; } @@ -302,7 +310,8 @@ private: double windowSec_ = 10.0; /* live scroll window width */ double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */ double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */ - int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed */ + int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed 3=unified */ + VScale plotUniVS_[kMaxPlotSlots]; /* shared scale used by vMode 3 */ /* Cursors (global) */ bool cursorsOn_ = false; diff --git a/Client/streamhub/PlotPanel.cpp b/Client/streamhub/PlotPanel.cpp index bbf5487..dfb9915 100644 --- a/Client/streamhub/PlotPanel.cpp +++ b/Client/streamhub/PlotPanel.cpp @@ -85,6 +85,49 @@ static double normalizeY(double raw, const VScale& vs) { return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos; } +/** Resolve the one scale every trace shares in unified mode. + * + * Same rules as the per-signal version, applied to the union of the plot: + * range takes the union of the declared ranges, auto fits the union of the + * data. Signals whose slot is empty contribute nothing. */ +static void resolveUnifiedVScale(VScale& vs, + const std::vector& slots, + const std::vector& sources, + const std::vector >& vStore) { + if (vs.mode == 2) { /* manual */ + vs.resolvedDiv = std::max(vs.divValue, 1e-30); + vs.resolvedOffset = vs.offset; + return; + } + double mn = 1e300, mx = -1e300; + if (vs.mode == 1) { /* range: union of every declared range */ + for (const auto& a : slots) { + if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue; + const auto& m = sources[a.sourceIdx].signals[a.signalIdx].meta; + if (!(m.rangeMax > m.rangeMin)) continue; + if (m.rangeMin < mn) mn = m.rangeMin; + if (m.rangeMax > mx) mx = m.rangeMax; + } + if (mx > mn) { + vs.resolvedDiv = std::max((mx - mn) / 8.0, 1e-30); + vs.resolvedOffset = (mn + mx) / 2.0; + return; + } + mn = 1e300; mx = -1e300; /* no usable range: fall through to auto */ + } + for (const auto& vv : vStore) { + for (double v : vv) { + if (!std::isfinite(v)) continue; + if (v < mn) mn = v; + if (v > mx) mx = v; + } + } + if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; } + if (mn == mx) { mn -= 1.0; mx += 1.0; } + vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30); + vs.resolvedOffset = (mx + mn) / 2.0; +} + /** Min/max of a vector (returns false if empty/non-finite). */ static bool dataMinMax(const std::vector& v, double& mn, double& mx) { mn = 1e300; mx = -1e300; @@ -149,9 +192,36 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { const double wallNow = std::chrono::duration( std::chrono::system_clock::now().time_since_epoch()).count(); - /* Trigger view: render the hub capture relative to the trigger instant */ + /* Trigger view: render the hub capture relative to the trigger instant. + * + * Two ways to end up in trigger-relative time. Either a v2 capture frame + * has arrived (trigView), or a trigger has fired and its window is still + * filling (trigFill). In the second case the hub sends nothing until the + * whole window has been produced — several seconds for a long window at a + * high rate — so the trace is drawn from this client's own rings on the + * final axis, growing left to right. Filling wins over the previous + * capture: once a new trigger fires, the stale waveform is history. */ const CaptureFrame* cap = app.capture(); - const bool trigView = (cap != nullptr) && app.showTrigBar(); + const TriggerState& trg = app.trigger(); + /* Prefer the window the hub latched at fire time; the local config is only + * a fallback for hubs that do not report it, and may have been edited + * since the trigger fired. */ + const double fillPreS = trg.hasFiredWin ? trg.firedPreS + : trg.windowSec * trg.prePercent * 0.01; + const double fillPostS = trg.hasFiredWin ? trg.firedPostS + : trg.windowSec - fillPreS; + + const bool trigFill = app.showTrigBar() && !paused && + trg.status == "collecting" && trg.hasTrigTime; + const bool trigView = (cap != nullptr) && app.showTrigBar() && !trigFill; + const bool trigRel = trigView || trigFill; + + /* Window edges of whatever is on screen. A capture latches its own + * pre/post at fire time, so later edits in the trigger bar must not move + * the axis of a finished capture. */ + const double trigT = trigView ? cap->trigTime : trg.trigTime; + const double trigPreS = trigView ? cap->preSec : fillPreS; + const double trigPostS = trigView ? cap->postSec : fillPostS; /* Hi-res zoom cache for this plot */ auto& zc = app.zoomCache(plotIdx); @@ -194,13 +264,13 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { * from decimated pushes) undersamples the visible range. Periodically * fetch a fresh ~2400-pt slice from the hub raw ring and anchor the X * axis to the fetched slice (scope-style refresh at the fetch rate). */ - const bool liveHiRes = !trigView && live && !paused && + const bool liveHiRes = !trigRel && live && !paused && app.windowSec() <= kLiveHiResMaxWin && zc.valid && (zc.t1 - zc.t0) >= app.windowSec() * 0.9 && (wallNow - zc.t1) < 3.0; - const bool useZoomData = !trigView && !paused && zc.valid && + const bool useZoomData = !trigRel && !paused && zc.valid && (liveHiRes || (!live && zc.t0 <= app.plotXMin(plotIdx) + 1e-9 && @@ -215,7 +285,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { const bool haveHistCover = hc.valid && hc.t0 <= app.plotXMin(plotIdx) + 1e-9 && hc.t1 >= app.plotXMax(plotIdx) - 1e-9; - bool useHistData = !trigView && !paused && !live && haveHistCover; + bool useHistData = !trigRel && !paused && !live && haveHistCover; if (useHistData) { /* Check that at least one signal has actual data points */ bool anyData = false; @@ -231,7 +301,12 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { * copied tens of MB per signal per frame. A 10% margin keeps a sample on * each side so the later fine clip still has its boundary points. */ double visT0, visT1; - if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); } + if (trigFill) { + /* Absolute bounds of the trigger window: the ring is indexed on the + * hub clock, the axis on trigger-relative time. */ + visT0 = trigT - trigPreS; visT1 = trigT + trigPostS; + } + else if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); } else { visT1 = app.plotXMax(plotIdx); visT0 = app.plotXMin(plotIdx); } { double margin = (visT1 - visT0) * 0.1; @@ -272,6 +347,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } break; } + } else if (trigFill) { + /* Live ring, clipped to the (absolute) window and shifted onto the + * trigger-relative axis. visT0/visT1 already carry a margin, so + * clip here rather than reusing readBase. */ + (void) sig.buf.readRange(trigT - trigPreS, trigT + trigPostS, + tStore[si], vStore[si]); + for (size_t i = 0; i < tStore[si].size(); i++) { + tStore[si][i] -= trigT; + } } else if (useZoomData) { bool found = false; for (const auto& zs : zc.signals) { @@ -302,6 +386,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { resolveVScale(a, sig, vStore[si]); } + VScale& uniVS = app.plotUnifiedVS(plotIdx); + if (vMode == 3) { + resolveUnifiedVScale(uniVS, slots, sources, vStore); + } + /* clamp active slot */ if (actSlot >= (int)slots.size()) actSlot = -1; @@ -326,9 +415,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { ImVec4(0.067f,0.067f,0.106f,1.f)); char badge[80]; - /* show vscale info: resolved div value */ + /* Show the div value actually in force: the plot's shared one in + * unified mode, this signal's otherwise. */ char dvbuf[16]; - fmtVal(dvbuf, sizeof(dvbuf), a.vs.resolvedDiv); + fmtVal(dvbuf, sizeof(dvbuf), + (vMode == 3) ? uniVS.resolvedDiv : a.vs.resolvedDiv); snprintf(badge, sizeof(badge), "%s %s/div##b%d", sig.meta.name.c_str(), dvbuf, i); @@ -398,7 +489,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } /* Back / Fit / Reset (zoom history) */ - if (!live || (trigView && app.trigZoomed(plotIdx))) { + if (!live || (trigRel && app.trigZoomed(plotIdx))) { ImGui::SameLine(); auto& hist = app.zoomHist(plotIdx); if (hist.empty()) { ImGui::BeginDisabled(); } @@ -408,7 +499,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } if (hist.empty()) { ImGui::EndDisabled(); } ImGui::SameLine(); - if (trigView) { + if (trigRel) { /* Reset to full capture window */ if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) { app.trigZoomed(plotIdx) = false; @@ -440,10 +531,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { /* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */ ImGui::SameLine(); { - static const char* kVLabels[] = {"N", "D", "M"}; - static const char* kVTooltips[] = {"Normal", "Digital", "Mixed"}; - for (int vm = 0; vm < 3; vm++) { - char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[vm], plotIdx, vm); + static const char* kVLabels[] = {"N", "U", "D", "M"}; + static const char* kVTooltips[] = { + "Normal: one vertical scale per signal", + "Unified: one vertical scale shared by every signal", + "Digital", "Mixed" }; + static const int kVModes[] = {0, 3, 1, 2}; + for (int i = 0; i < 4; i++) { + const int vm = kVModes[i]; + char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[i], plotIdx, vm); bool sel = (vMode == vm); if (sel) { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f)); @@ -451,28 +547,41 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } if (ImGui::SmallButton(vmId)) { vMode = vm; } if (sel) { ImGui::PopStyleColor(2); } - if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[vm]); } - if (vm < 2) { ImGui::SameLine(0.f, 1.f); } + if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[i]); } + if (i < 3) { ImGui::SameLine(0.f, 1.f); } } } - /* ── VScale toolbar (shown when an active signal is selected) ───────── */ + /* ── VScale toolbar ──────────────────────────────────────────────────── * + * Normal mode edits the active signal's scale; unified mode edits the one + * scale the whole plot shares, so it needs no selection. */ + VScale *toolVS = static_cast(0); if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { - auto& a = slots[actSlot]; + toolVS = &slots[actSlot].vs; + } else if (vMode == 3) { + toolVS = &uniVS; + } + if (toolVS != static_cast(0)) { + VScale& tvs = *toolVS; ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f)); + if (vMode == 3) { + ImGui::TextDisabled("all signals"); + ImGui::SameLine(0.f,10.f); + } + /* mode buttons */ static const char* kModeLabels[] = {"Auto","Range","Manual"}; for (int m = 0; m < 3; m++) { - bool sel = (a.vs.mode == m); + bool sel = (tvs.mode == m); if (sel) { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.3f)); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.537f,0.706f,0.980f,1.f)); } - if (ImGui::SmallButton(kModeLabels[m])) { a.vs.mode = m; } + if (ImGui::SmallButton(kModeLabels[m])) { tvs.mode = m; } if (sel) ImGui::PopStyleColor(2); if (m < 2) ImGui::SameLine(0.f,2.f); } @@ -480,23 +589,23 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { /* resolved info */ char rbuf[24], obuf[24]; - fmtVal(rbuf, sizeof(rbuf), a.vs.resolvedDiv); - fmtVal(obuf, sizeof(obuf), a.vs.resolvedOffset); + fmtVal(rbuf, sizeof(rbuf), tvs.resolvedDiv); + fmtVal(obuf, sizeof(obuf), tvs.resolvedOffset); - if (a.vs.mode == 2) { /* manual: editable */ + if (tvs.mode == 2) { /* manual: editable */ ImGui::SetNextItemWidth(70.f); - ImGui::InputDouble("V/div##vd", &a.vs.divValue, 0,0,"%.4g"); + ImGui::InputDouble("V/div##vd", &tvs.divValue, 0,0,"%.4g"); ImGui::SameLine(0.f,4.f); ImGui::SetNextItemWidth(80.f); - ImGui::InputDouble("Offset##vo", &a.vs.offset, 0,0,"%.4g"); + ImGui::InputDouble("Offset##vo", &tvs.offset, 0,0,"%.4g"); } else { ImGui::TextDisabled("%s/div @%s", rbuf, obuf); } ImGui::SameLine(0.f,10.f); ImGui::SetNextItemWidth(50.f); - float sp = (float)a.vs.screenPos; + float sp = (float)tvs.screenPos; if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) { - a.vs.screenPos = sp; + tvs.screenPos = sp; } ImGui::PopStyleVar(); @@ -539,7 +648,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) { /* Both axes locked so ImPlot never overrides our explicit limits. */ - ImPlot::SetupAxes(trigView ? "t - trig (s)" : "Time (s)", nullptr, + ImPlot::SetupAxes(trigRel ? "t - trig (s)" : "Time (s)", nullptr, ImPlotAxisFlags_Lock, ImPlotAxisFlags_Lock); @@ -549,13 +658,17 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { /* X axis: trig view → capture window (zoomable); live → wall clock; else stored */ double xMin, xMax; bool& trigZm = app.trigZoomed(plotIdx); - if (trigView) { + if (trigRel) { if (trigZm) { xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx); } else { - xMin = -cap->preSec; - xMax = cap->postSec; + /* Full window from the start, even while filling: a trace that + * grows into a fixed axis reads as progress; an axis that + * grows with the data makes the whole trace shift every + * frame and the time base meaningless. */ + xMin = -trigPreS; + xMax = trigPostS; } } else if (live && !paused) { if (liveHiRes) { @@ -568,7 +681,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } else { xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx); } - if (trigView || (live && !paused) || !live) { + if (trigRel || (live && !paused) || !live) { if (xMax > xMin) { ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always); } @@ -579,8 +692,16 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { static char yTickBufs[9][20]; static const char* yTickLabels[9]; + const VScale *axisVS = static_cast(0); if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { - const auto& av = slots[actSlot].vs; + axisVS = &slots[actSlot].vs; + } else if (vMode == 3) { + /* Unified: the shared scale labels the axis for every trace at + * once, so no signal has to be selected first. */ + axisVS = &uniVS; + } + if (axisVS != static_cast(0)) { + const VScale& av = *axisVS; for (int d = 0; d < 9; d++) { double divPos = yTickVals[d]; double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv; @@ -636,15 +757,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { /* Helper: enter zoomed mode for trigger view (seed from capture window) */ auto enterTrigZoom = [&]() { - if (trigView && !trigZm) { - app.setPlotX(plotIdx, -cap->preSec, cap->postSec); + if (trigRel && !trigZm) { + app.setPlotX(plotIdx, -trigPreS, trigPostS); trigZm = true; } }; /* Helper: X-zoom the stored range by factor around center */ auto xZoomStored = [&](double factor) { - if (trigView) { enterTrigZoom(); } + if (trigRel) { enterTrigZoom(); } if (now - lastHistPush[plotIdx] > 0.6) { app.pushZoomHist(plotIdx); lastHistPush[plotIdx] = now; @@ -659,37 +780,45 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { const double zoomOut = 1.25; double factor = (wheel > 0.f) ? zoomIn : zoomOut; + /* Scroll adjusts the scale the axis is labelled with: the + * active signal's in normal mode, the plot's shared one in + * unified mode (where there is nothing to select). */ + VScale *wheelVS = static_cast(0); + if (vMode == 3) { + wheelVS = &uniVS; + } else if (actSlot >= 0 && actSlot < (int)slots.size()) { + wheelVS = &slots[actSlot].vs; + } + /* Seed manual from the resolved values so the gesture sticks. */ + auto latchManual = [](VScale& v) { + if (v.mode != 2) { + v.divValue = std::max(v.resolvedDiv, 1e-30); + v.offset = v.resolvedOffset; + v.mode = 2; + } + }; + if (ctrl) { /* ── X zoom ─────────────────────────────────────────── */ - if (!trigView && live) { + if (!trigRel && live) { app.setWindowSec(app.windowSec() * factor); } else { xZoomStored(factor); } } else if (shift) { - /* ── Y offset of active signal ───────────────────────── */ - if (actSlot >= 0 && actSlot < (int)slots.size()) { - auto& a = slots[actSlot]; - if (a.vs.mode != 2) { - a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30); - a.vs.offset = a.vs.resolvedOffset; - a.vs.mode = 2; - } - a.vs.screenPos += (wheel > 0.f) ? 0.5 : -0.5; + /* ── Y pan ───────────────────────────────────────────── */ + if (wheelVS != static_cast(0)) { + latchManual(*wheelVS); + wheelVS->screenPos += (wheel > 0.f) ? 0.5 : -0.5; } } else { - /* ── Y zoom of active signal ─────────────────────────── */ - if (actSlot >= 0 && actSlot < (int)slots.size()) { - auto& a = slots[actSlot]; - if (a.vs.mode != 2) { - a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30); - a.vs.offset = a.vs.resolvedOffset; - a.vs.mode = 2; - } - a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30); + /* ── Y zoom ──────────────────────────────────────────── */ + if (wheelVS != static_cast(0)) { + latchManual(*wheelVS); + wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30); } else { /* No active signal: plain scroll → X zoom */ - if (!trigView && live) { + if (!trigRel && live) { app.setWindowSec(app.windowSec() * factor); } else { xZoomStored(factor); @@ -701,8 +830,8 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { /* Right-drag → X pan. Transition live→non-live on drag start; * in trigger view, enter trigger-zoom mode. */ if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) { - if (trigView) { enterTrigZoom(); } - if (!trigView && live) { + if (trigRel) { enterTrigZoom(); } + if (!trigRel && live) { app.initPlotX(plotIdx, wallNow); live = false; lastHistPush[plotIdx] = now; @@ -721,7 +850,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } /* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */ - if (!trigView && !paused) { + if (!trigRel && !paused) { std::string csv; for (const auto& a : slots) { std::string k = app.slotKey(a); @@ -821,9 +950,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } else if (vMode == 2) { /* mixed */ bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed); } else { + /* unified shares one scale, normal gives each trace its own */ + const VScale& nvs = (vMode == 3) ? uniVS : a.vs; vNorm.resize(nOut); for (size_t k = 0; k < nOut; k++) { - vNorm[k] = normalizeY(vDec[k], a.vs); + vNorm[k] = normalizeY(vDec[k], nvs); } } @@ -836,7 +967,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) { } /* Trigger instant marker (capture view: t = 0) */ - if (trigView) { + if (trigRel) { double t0m = 0.0; ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f), 1.5f, ImPlotDragToolFlags_NoInputs); diff --git a/Client/streamhub/Protocol.cpp b/Client/streamhub/Protocol.cpp index bf6ffb3..7e448ea 100644 --- a/Client/streamhub/Protocol.cpp +++ b/Client/streamhub/Protocol.cpp @@ -458,6 +458,12 @@ bool ParseTriggerState(const std::string& json, TriggerStateMsg& out) { double tt = 0.0; out.hasTrigTime = jsonGetDouble(json.c_str(), "trigTime", tt); out.trigTime = tt; + + double pre = 0.0, post = 0.0; + out.hasWindow = jsonGetDouble(json.c_str(), "preSec", pre) && + jsonGetDouble(json.c_str(), "postSec", post); + out.preSec = pre; + out.postSec = post; return true; } diff --git a/Client/streamhub/Protocol.h b/Client/streamhub/Protocol.h index 8050df3..49472b1 100644 --- a/Client/streamhub/Protocol.h +++ b/Client/streamhub/Protocol.h @@ -109,6 +109,11 @@ struct TriggerStateMsg { bool stopped = false; bool hasTrigTime = false; double trigTime = 0.0; + /* Window latched at fire time, sent alongside trigTime. Older hubs omit + * it, hence hasWindow — fall back to the local trigger config then. */ + bool hasWindow = false; + double preSec = 0.0; + double postSec = 0.0; }; /*---------------------------------------------------------------------------*/ diff --git a/Client/udpstreamer/main.go b/Client/udpstreamer/main.go index b6f7712..4eb9fc2 100644 --- a/Client/udpstreamer/main.go +++ b/Client/udpstreamer/main.go @@ -9,6 +9,9 @@ import ( "log" "net/http" "os" + "os/signal" + "path/filepath" + "syscall" "marte2/common/wshub" ) @@ -21,20 +24,56 @@ var staticFiles embed.FS // multiFlag allows a flag to be repeated: --source a --source b type multiFlag []string -func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) } -func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil } +func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) } +func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil } + +// defaultHistoryDir is where samples are archived unless -history-dir says +// otherwise. History is on by default because it is what holds a trigger +// capture at full resolution: the in-memory rings roll past a captured window +// within seconds of it being taken, and a zoom after that has nothing but the +// capture's own decimated copy to draw. Per-signal files are bounded by +// -history-max-mpts, so the default costs a fixed amount of space. +func defaultHistoryDir() string { + return filepath.Join(os.TempDir(), "udpstreamer-history") +} func main() { var sourceArgs multiFlag flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`) sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)") listenAddr := flag.String("addr", ":8080", "HTTP listen address") + histDir := flag.String("history-dir", defaultHistoryDir(), "Directory for disk-backed signal history (empty disables it)") + histWindow := flag.Float64("history-window-sec", 0, "Timespan the history files hold before any client says what it displays (0 keeps the 10 s default); the hub re-sizes them to the live or trigger window afterwards") + histDecim := flag.Int("history-decimation", 1, "Keep every Nth sample in the history files") + histFlush := flag.Int("history-flush-sec", 5, "Seconds between history header flushes") + histMinFree := flag.Int("history-min-free-mb", 500, "Pause history writing below this much free disk (negative disables the check)") + histMaxMPts := flag.Float64("history-max-mpts", 0, "Per-signal history budget in millions of points, also settable in the web UI (0 keeps the 16 MPts / 256 MB default)") + ringMPts := flag.Float64("ring-mpts", 0, "Per-signal in-memory buffer in millions of points (0 keeps the 10 MPts / 160 MB default)") flag.Parse() hub := wshub.NewHub() + // The budget bounds memory, not the window: a window too long to hold at the + // source rate is buffered as min/max pairs rather than truncated to the tail. + hub.SetRingBudget(int(*ringMPts * 1e6)) sm := wshub.NewSourceManager(hub, *sourcesFile) hub.SetSourceManager(sm) + if err := hub.EnableHistory(wshub.HistoryConfig{ + Directory: *histDir, + WindowSec: *histWindow, + Decimation: *histDecim, + FlushIntervalSec: *histFlush, + MinDiskFreeMB: *histMinFree, + MaxPointsPerSignal: int(*histMaxMPts * 1e6), + }); err != nil { + log.Fatalf("history: %v", err) + } + if *histDir == "" { + log.Print("history disabled: zooming into a trigger capture will fall back " + + "to the capture's own decimated copy once the rings roll past it") + } else { + log.Printf("history: %s", *histDir) + } go hub.Run() // Load sources from file first (if specified), then add any CLI --source flags. @@ -60,7 +99,21 @@ func main() { }) log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion) - if err := http.ListenAndServe(*listenAddr, nil); err != nil { + + // Serve in the background so Ctrl-C can flush the history files: the + // samples written since the last periodic flush are on disk but are not + // yet accounted for in the file headers, so exiting outright loses them. + srvErr := make(chan error, 1) + go func() { srvErr <- http.ListenAndServe(*listenAddr, nil) }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + select { + case err := <-srvErr: + hub.CloseHistory() log.Fatalf("http: %v", err) + case s := <-sig: + log.Printf("received %s, flushing history", s) + hub.CloseHistory() } } diff --git a/Client/udpstreamer/static/app.js b/Client/udpstreamer/static/app.js index a80ca33..778cd68 100644 --- a/Client/udpstreamer/static/app.js +++ b/Client/udpstreamer/static/app.js @@ -2,21 +2,40 @@ /* ════════════════════════════════════════════════════════════════ 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. +// Hard ceiling per live buffer (~32 MB per signal at Float64 t+v). This bounds +// memory, not the window: buffers start small and are grown only as far as the +// selected window needs (see growBufferForWindow). +const MAX_CAP = 2_000_000; +// Starting capacity for a scalar signal; grown from there if the window needs it. 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 +// Starting capacity for a temporal (array) signal. The hub caps the live push at +// 50 points per signal per 30 Hz tick, so 500 k points is already ~5 minutes. +const TEMPORAL_CAP = 500_000; +const DECIM_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)); +// Seconds of data currently held by a buffer. +function bufferSpanSec(buf) { + if (buf.size < 2) return 0; + const start = buf.size === buf.cap ? buf.head : 0; + return buf.t[(start + buf.size - 1) % buf.cap] - buf.t[start]; +} + +/* Grow buf until what it holds covers windowSec. + + Capacity is in points but the window is in seconds, and the two are related + by the rate at which points *arrive* — which is not the signal's sampling + rate. The hub decimates the live push to 50 points per signal per tick, so a + 1 MSps channel lands here at ~1.5 kpts/s. Sizing from the sampling rate + overshot by three orders of magnitude, saturated at the ceiling, and left the + longest windows showing only their tail. Measuring the buffer's own span + needs no rate model and is right for every signal type — scalars pushed once + per packet, decimated temporal streams, and snapshot waveforms alike. */ +function growBufferForWindow(buf, windowSec) { + if (buf.size < buf.cap) return buf; // not full yet: it still reaches further back + const span = bufferSpanSec(buf); + const want = windowSec * 1.5; // headroom, so it is not re-grown every frame + if (span <= 0 || span >= want) return buf; + return growBuffer(buf, Math.min(MAX_CAP, Math.ceil(buf.cap * want / span))); } // Return a new, larger circular buffer that preserves all existing samples. @@ -82,10 +101,20 @@ function setSigStyle(key, updates) { } /* ─── VScale helpers ─────────────────────────────────────────────────────── */ -// vsKey: compound key "plotId:signalKey" so same signal in different plots is independent. +// Key used to look a plot's vertical scale up. Normally compound, +// "plotId:signalKey", so the same signal in two plots scales independently. In +// unified mode every trace shares one scale, so they all collapse onto +// "plotId:*"; doing the redirect here rather than at each call site means the Y +// axis, cursors, rulers, offset markers and the scale panel follow for free. +const UNIFIED_VS_KEY = '*'; +function vsKeyFor(plotId, key) { + const p = plots.find(q => q.id === plotId); + return plotId + ':' + ((p && p.mode === 'unified') ? UNIFIED_VS_KEY : key); +} + 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 }; + const vsKey = vsKeyFor(plotId, key); + if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, _resolvedDiv: null, _resolvedOffset: null, digitalInMixed: false }; return sigVScale[vsKey]; } @@ -170,13 +199,12 @@ function calibrateArray(key, rawY) { return out; } -// 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 +// Resolve the effective {divValue, offset} for a signal given its raw data array. +// y_norm = (y_raw - offset) / divValue +// divValue: units per division offset: raw value at screen centre // 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) { @@ -187,7 +215,7 @@ function resolveVScale(plotId, key, rawY) { const divValue = niceDiv((hi - lo) / 8); const offset = Math.round((lo + hi) / 2 / divValue) * divValue; vs._resolvedDiv = divValue; vs._resolvedOffset = offset; - return { divValue, offset, screenPos }; + return { divValue, offset }; } // Fall through to auto if no range } @@ -195,11 +223,11 @@ function resolveVScale(plotId, key, rawY) { 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 }; + return { divValue, offset }; } - // Auto: fit data in central 6 of 8 divisions, centered at screenPos. - // Both the V/div and the centre offset are snapped so gridlines (and the - // zero line, when in view) fall on round values. + // Auto: fit data in central 6 of 8 divisions. Both the V/div and the centre + // offset are snapped so gridlines (and the zero line, when in view) fall on + // round values. let min = Infinity, max = -Infinity; for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; @@ -210,7 +238,7 @@ function resolveVScale(plotId, key, rawY) { const divValue = niceDiv(Math.max((max - min) / 6, 1e-30)); const offset = Math.round((max + min) / 2 / divValue) * divValue; vs._resolvedDiv = divValue; vs._resolvedOffset = offset; - return { divValue, offset, screenPos }; + return { divValue, offset }; } // Mixed mode: each signal occupies a fixed band; within that band it is either @@ -249,21 +277,77 @@ function applyMixedNorm(p, yArrays) { }); } +// Unified mode: one vertical scale for the whole plot instead of one per +// signal, so traces can be compared against each other directly. Auto fits the +// union of every trace, range the union of their declared ranges; manual is a +// single per-plot setting. Calibration has already been applied to calArrays, +// so the shared scale is in calibrated units like the per-signal one. +function resolveUnifiedVScale(p, calArrays) { + const vs = getVScale(p.id, UNIFIED_VS_KEY); + 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 }; + } + let min = Infinity, max = -Infinity; + if (vs.mode === 'range') { + p.traces.forEach(key => { + const meta = findSignalMeta(key); + if (!meta || meta.rangeMin == null || meta.rangeMax == null || + meta.rangeMax <= meta.rangeMin) return; + const [lo, hi] = Calib.calRange(meta.rangeMin, meta.rangeMax, calForKey(key)); + if (lo < min) min = lo; + if (hi > max) max = hi; + }); + if (isFinite(min) && max > min) { + const divValue = niceDiv((max - min) / 8); + const offset = Math.round((max + min) / 2 / divValue) * divValue; + vs._resolvedDiv = divValue; vs._resolvedOffset = offset; + return { divValue, offset }; + } + min = Infinity; max = -Infinity; // no trace declared a range: fall back to auto + } + for (const y of calArrays) { + for (let i = 0; i < y.length; i++) { + const v = y[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 = niceDiv(Math.max((max - min) / 6, 1e-30)); + const offset = Math.round((max + min) / 2 / divValue) * divValue; + vs._resolvedDiv = divValue; vs._resolvedOffset = offset; + return { divValue, offset }; +} + // Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces). // Calibration is applied first, so divValue/offset — and therefore the cursor, // hover, ruler and Y-axis readouts derived from them — are all in calibrated -// units. Returns y_norm = (y_cal - offset) / divValue + screenPos. +// units. Returns y_norm = (y_cal - offset) / divValue. function applyVScaleNorm(p, yArrays) { const calArrays = yArrays.map((rawY, ki) => calibrateArray(p.traces[ki], rawY)); if (p.mode === 'digital') return applyDigitalNorm(p, calArrays); if (p.mode === 'mixed') return applyMixedNorm(p, calArrays); + if (p.mode === 'unified') { + const { divValue, offset } = resolveUnifiedVScale(p, calArrays); + return calArrays.map(y => { + const out = new Float64Array(y.length); + for (let i = 0; i < y.length; i++) { + const v = y[i]; + out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue; + } + return out; + }); + } return calArrays.map((y, ki) => { const key = p.traces[ki]; - const { divValue, offset, screenPos } = resolveVScale(p.id, key, y); + const { divValue, offset } = resolveVScale(p.id, key, y); const out = new Float64Array(y.length); for (let i = 0; i < y.length; i++) { const v = y[i]; - out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos; + out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue; } return out; }); @@ -329,18 +413,40 @@ function _fmtVal(v) { return parseFloat(v.toPrecision(3)).toString(); } +// Format a whole row/column of axis ticks. _fmtVal formats each value on its +// own merits, which collapses every tick to the same text once the values are +// large compared to the step between them (a Counter around 8.6e5 stepping by +// 5e3 printed "8.6e+5" nine times). Pick the precision from the tick step so +// neighbouring labels always differ. +function _fmtTickVals(vals) { + const nums = vals.filter(v => v != null && isFinite(v)); + if (nums.length < 2) return vals.map(v => (v == null ? '' : _fmtVal(v))); + let step = Infinity; + for (let i = 1; i < nums.length; i++) { + const d = Math.abs(nums[i] - nums[i - 1]); + if (d > 0 && d < step) step = d; + } + const maxAbs = Math.max(...nums.map(Math.abs)); + if (!isFinite(step) || step === 0 || maxAbs === 0) { + return vals.map(v => (v == null ? '' : _fmtVal(v))); + } + if (maxAbs >= 1e6 || maxAbs < 1e-3) { + const mant = Math.min(6, Math.max(0, Math.ceil(Math.log10(maxAbs / step)))); + return vals.map(v => (v == null ? '' : v.toExponential(mant))); + } + const dec = Math.min(6, Math.max(0, Math.ceil(-Math.log10(step)))); + return vals.map(v => (v == null ? '' : v.toFixed(dec))); +} + // 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]; + const vs = sigVScale[vsKeyFor(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; + infoEl.textContent = _fmtVal(divValue) + '/div'; } // Sync: shared uPlot cursor crosshair across all live plots @@ -389,11 +495,47 @@ let _gridCols = 1, _gridRows = 1; ════════════════════════════════════════════════════════════════ */ const trig = { enabled: false, signal: '', edge: 'rising', threshold: 0, windowSec: 1, - prePercent: 20, mode: 'normal', stopped: false, + prePercent: 20, mode: 'normal', holdoffSec: 0.2, stopped: false, armed: false, collecting: false, trigTime: null, snapshot: null, + // Window latched by the hub at fire time (null until a trigger fires). + firedPreS: null, firedPostS: null, }; -function trigPreSec() { return trig.windowSec * trig.prePercent / 100; } -function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; } +// Edges of the window currently being filled. The hub reports what it latched +// at fire time; the config below is only a fallback for hubs that do not, and +// may have been edited since the trigger fired. +function trigPreSec() { + return trig.firedPreS !== null ? trig.firedPreS + : trig.windowSec * trig.prePercent / 100; +} +function trigPostSec() { + return trig.firedPostS !== null ? trig.firedPostS + : trig.windowSec * (100 - trig.prePercent) / 100; +} + +/* Plots render in trigger-relative time in two situations: a finished capture + is on screen, or a trigger has fired and its post-window is still filling. + In the second case the hub has not sent the v2 frame yet (it waits for the + whole window plus a margin — several seconds for a long window), so the + trace is drawn live from this client's own buffers on the final x-axis. */ +function inTrigWindow() { + return trig.enabled && + (trig.snapshot !== null || (trig.collecting && trig.trigTime !== null)); +} +// True while the window is filling and only live data is available. +function trigFilling() { + return trig.enabled && trig.snapshot === null && trig.collecting && + trig.trigTime !== null; +} +// Window edges of whatever is on screen: a capture latches its own pre/post at +// fire time, so later UI edits must not move the axis of a finished capture. +function activePreSec() { + return (trig.snapshot && trig.snapshot._preS !== undefined) + ? trig.snapshot._preS : trigPreSec(); +} +function activePostSec() { + return (trig.snapshot && trig.snapshot._postS !== undefined) + ? trig.snapshot._postS : trigPostSec(); +} /* ════════════════════════════════════════════════════════════════ WebSocket @@ -425,6 +567,7 @@ function connectWS() { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'setMonotonic', enabled: monoPref })); } + sendWindow(); }; ws.onclose = () => { setStatus('red', 'Disconnected (reconnecting…)'); @@ -439,6 +582,7 @@ function connectWS() { 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 === 'triggerState') onTriggerState(msg); else if (msg.type === 'zoom') onZoomReply(msg); else if (msg.type === 'historyZoom') onHistoryZoomReply(msg); else if (msg.type === 'historyInfo') onHistoryInfo(msg); @@ -491,7 +635,9 @@ function onZoomReply(msg) { /* ════════════════════════════════════════════════════════════════ History (disk-backed data from the hub) ════════════════════════════════════════════════════════════════ */ -let historyMeta = null; // {enabled, durationHours, decimation, signals:{key:{t0,t1,count,capacity}}} +// signals: key → {t0,t1,count,capacity,bucket}. bucket is how many source +// samples the hub folds into one archived min/max pair; 1 means verbatim. +let historyMeta = null; // {enabled, windowSec, decimation, maxMPts, signals} const historyData = {}; // plotId → {signals:{key:{t,v}}, t0, t1} const _histPending = new Map(); @@ -519,8 +665,11 @@ function onHistoryZoomReply(msg) { function onHistoryInfo(msg) { historyMeta = { enabled: msg.enabled || false, - durationHours: msg.durationHours || 0, + // The span the archive is sized to hold. The C++ StreamHub instead keeps a + // fixed retention period and reports it in hours. + windowSec: msg.windowSec || (msg.durationHours || 0) * 3600, decimation: msg.decimation || 1, + maxMPts: msg.maxMPtsPerSignal || 0, signals: msg.signals || {}, }; updateHistoryUI(); @@ -531,11 +680,67 @@ function updateHistoryUI() { if (!badge) return; if (historyMeta && historyMeta.enabled) { const nSigs = Object.keys(historyMeta.signals).length; - badge.textContent = `History: ${historyMeta.durationHours}h, ${nSigs} signals`; + badge.textContent = + `History: ${fmtSpan(historyMeta.windowSec)}, ${nSigs} signals, ${fmtMPts(historyMeta.maxMPts)}/sig`; badge.style.display = ''; } else { badge.style.display = 'none'; } + if (histPanelOpen) renderHistoryPanel(); +} + +// A timespan for the status bar: the archive covers seconds to minutes now that +// it is sized from the displayed window rather than from a retention period. +function fmtSpan(sec) { + if (!(sec > 0)) return '—'; + if (sec < 1) return `${Math.round(sec * 1000)} ms`; + if (sec < 90) return `${+sec.toFixed(sec < 10 ? 1 : 0)} s`; + if (sec < 5400) return `${+(sec / 60).toFixed(1)} min`; + return `${+(sec / 3600).toFixed(1)} h`; +} + +function fmtMPts(m) { + if (!(m > 0)) return '—'; + return m >= 1 ? `${(+m.toFixed(3))} MPts` : `${Math.round(m * 1000)} kPts`; +} + +/* ── Budget popup ───────────────────────────────────────────────── */ +let histPanelOpen = false; + +function toggleHistoryPanel() { + histPanelOpen = !histPanelOpen; + const panel = document.getElementById('history-panel'); + if (!histPanelOpen) { panel.style.display = 'none'; return; } + document.getElementById('hist-budget').value = + historyMeta ? +historyMeta.maxMPts.toFixed(3) : ''; + renderHistoryPanel(); + panel.style.display = ''; + // Anchored above the badge, since the badge sits in the status bar. + const r = document.getElementById('history-badge').getBoundingClientRect(); + panel.style.left = `${Math.max(4, r.left)}px`; + panel.style.top = `${Math.max(4, r.top - panel.offsetHeight - 6)}px`; +} + +function renderHistoryPanel() { + const box = document.getElementById('hist-signal-res'); + const sigs = (historyMeta && historyMeta.signals) || {}; + const keys = Object.keys(sigs).sort(); + if (!keys.length) { box.innerHTML = '
no signals archived yet
'; return; } + box.innerHTML = keys.map(k => { + const s = sigs[k]; + const bucket = s.bucket || 1; + // The bucket is what the budget actually controls, so it is what to show. + const res = bucket > 1 ? `min/max ÷${bucket}` : 'full res'; + return `
${k}` + + `${res}
`; + }).join(''); +} + +function applyHistoryBudget() { + const v = parseFloat(document.getElementById('hist-budget').value); + if (!(v > 0)) return; + wsSend({ type: 'setHistoryBudget', maxMPtsPerSignal: v }); + toggleHistoryPanel(); } /* ════════════════════════════════════════════════════════════════ @@ -597,10 +802,11 @@ function onConfig(msg) { newSigs.forEach(sig => { const n = numElements(sig); const base = prefix + sig.name; - const sigCap = bufferCapForRate(sig.samplingRate); - if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); } - else if (n === 1) { buffers[base] = makeBuffer(sigCap); } - else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(sigCap); } + // Both hubs flatten an array signal into a single series keyed by the + // signal name (element i of a packet is sample i in time), so a buffer + // per element could never be written to — for a 1000-element signal that + // reserved ~1.5 GB of dead memory. + buffers[base] = makeBuffer(n > 1 ? TEMPORAL_CAP : DEFAULT_CAP); }); if (trig.signal && trig.signal.startsWith(prefix)) { trigDisarm(); trig.snapshot = null; @@ -675,13 +881,10 @@ function onBinaryData(buf) { bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP); buffers[fullKey] = bufObj; } - // If the buffer was created before the CONFIG message arrived (or with an - // underestimated capacity), grow it now that we know the signal's rate. - const detectedRate = getKeySamplingRate(fullKey); - if (detectedRate > 0) { - const needed = bufferCapForRate(detectedRate); - if (needed > bufObj.cap) { bufObj = growBuffer(bufObj, needed); buffers[fullKey] = bufObj; } - } + // Once full, grow towards the selected window. Only measuring can tell how + // many points a second of this signal costs, so this is where it happens. + const grown = growBufferForWindow(bufObj, windowSec); + if (grown !== bufObj) { bufObj = grown; 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; @@ -691,8 +894,17 @@ function onBinaryData(buf) { off += n * 16; // skip both t and v arrays } + // Unconditionally: the generation is what tells decimateAsync its cached + // result is stale. Gating it on !trig.enabled froze the trigger-fill trace, + // because buildTrigFillData caches under a key that does not change as the + // window grows and relies on the generation alone to invalidate it. Only + // windows short enough to stay under targetPts escaped, since those skip the + // cache entirely — which is why just the long ones stopped sweeping. + _dataGen++; + // Marking plots dirty stays live-only: while a trigger is armed the plot is + // deliberately frozen on its last render, and the render loop marks a + // *filling* window dirty on its own. if (!trig.enabled) { - _dataGen++; plots.forEach(p => { if (globalPause) return; if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true; @@ -706,6 +918,13 @@ function onBinaryData(buf) { function wsSend(obj) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); } +// Tell the hub how far back we are plotting. It sizes its per-signal buffers +// from the widest window any client reports: a window it does not know about is +// a window whose start may already have rolled out of the ring, which is what a +// zoom on a long timescale then has nothing to answer with. +function sendWindow() { + wsSend({ type: 'setWindow', seconds: windowSec }); +} // trig.threshold is held in calibrated units. The hub's comparator runs on raw // samples, so invert on the way out: raw = (calibrated - offset) / scale. function sendTrigConfig() { @@ -713,7 +932,7 @@ function sendTrigConfig() { wsSend({ type: 'setTrigger', signal: trig.signal, edge: trig.edge, threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec, - prePercent: trig.prePercent, mode: trig.mode, + prePercent: trig.prePercent, mode: trig.mode, holdoffSec: trig.holdoffSec, }); } @@ -730,9 +949,21 @@ function onTriggerState(msg) { const st = msg.state || 'idle'; trig.stopped = !!msg.stopped; updateStopBtn(); + // Window the hub latched at fire time. windowSec/prePercent below are the + // editable config and may have moved on since, so the fill axis must use + // this when the hub reports it. + if (msg.preSec !== undefined && msg.postSec !== undefined) { + trig.firedPreS = msg.preSec; trig.firedPostS = msg.postSec; + } if (st === 'armed') { trig.armed = true; trig.collecting = false; - setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('armed'); + // bufferFill is only sent while the hub is holding off: its buffers do not + // yet reach back far enough for a capture taken now to come back whole, so + // edges are ignored on purpose. Show it filling rather than leaving an + // armed trigger that looks broken. + const fill = msg.bufferFill; + setAllCardsCollecting(false); showRearmBtn(false); + updateTrigStatusBadge('armed', fill !== undefined ? `${Math.floor(fill * 100)}%` : ''); } else if (st === 'collecting') { trig.armed = false; trig.collecting = true; if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime; @@ -740,6 +971,10 @@ function onTriggerState(msg) { // capture replaces it when the v2 frame arrives. trig.snapshot = null; updateTrigStatusBadge('waiting'); setAllCardsCollecting(true); + // Show the (still empty) trigger window immediately and let live data + // sweep into it: the hub only sends the capture once the whole window has + // elapsed, which is several seconds for a long window. + plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); } else if (st === 'triggered') { trig.armed = false; trig.collecting = false; if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime; @@ -748,6 +983,7 @@ function onTriggerState(msg) { showStopBtn(trig.mode === 'normal'); } else { // idle trig.armed = false; trig.collecting = false; + trig.firedPreS = null; trig.firedPostS = null; setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('idle'); } } @@ -756,6 +992,12 @@ function onTriggerState(msg) { // [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) { + // The trigger lives in the hub and stays armed across client sessions, so a + // client that has not turned the trigger on locally still receives captures + // armed by someone else (or by its own previous page load). Applying them + // would silently drop this client's zoom and vertical scales while the UI is + // in plain live mode, so ignore captures we did not ask for. + if (!trig.enabled) return; const trigTime = dv.getFloat64(off, true); off += 8; const preS = dv.getFloat64(off, true); off += 8; const postS = dv.getFloat64(off, true); off += 8; @@ -785,7 +1027,13 @@ function onTriggerCapture(dv, buf, off) { 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; }); + // Drop any horizontal zoom so the whole new capture is visible, but leave the + // vertical scales alone: V/div and offset are settings the user dialled in and + // they must survive from shot to shot. + plots.forEach(p => { + p.xRange = null; + p.needsRedraw = true; + }); } function trigArm() { @@ -807,10 +1055,16 @@ function trigDisarm() { function setAllCardsCollecting(on) { document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on)); } -function updateTrigStatusBadge(state) { +// note is appended to the label without changing the state colour, used for the +// pre-fill percentage of an armed-but-holding-off trigger. +function updateTrigStatusBadge(state, note) { const el = document.getElementById('trig-status-badge'); el.className = state; - el.textContent = { idle: 'IDLE', armed: 'ARMED', waiting: 'COLLECTING', triggered: 'TRIGGERED' }[state] || 'IDLE'; + const label = { idle: 'IDLE', armed: 'ARMED', waiting: 'COLLECTING', triggered: 'TRIGGERED' }[state] || 'IDLE'; + el.textContent = note ? `${label} ${note}` : label; + el.title = note + ? 'Waiting for the hub to buffer enough history to deliver a whole window' + : ''; } 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'; } @@ -1006,121 +1260,131 @@ function makeSeriesPath(key) { } /* ════════════════════════════════════════════════════════════════ - LTTB Web Worker — offloads decimation off the main thread. + Decimation Web Worker — offloads decimation off the main thread. Cache key: "::::" when zoomed, or "::rolling" in live mode, where the data generation is passed alongside instead of being part of the key. On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate). - On cache-miss → render falls back to sync lttb once (first render only), + On cache-miss → render falls back to sync decimate once (first render only), then worker takes over for subsequent updates. ════════════════════════════════════════════════════════════════ */ -const lttbCache = new Map(); // key → {t, v, gen} -const lttbPending = new Map(); // key → generation currently in flight +const decimCache = new Map(); // key → {t, v, gen} +const decimPending = new Map(); // key → generation currently in flight // Hard ceiling on cached decimations. Rolling-mode keys are stable (one entry // per plot), but zoom keys embed the range, so without a cap the map would grow // for the whole session. -const LTTB_CACHE_MAX = 256; +const DECIM_CACHE_MAX = 256; // Store a decimation, re-inserting so Map iteration order stays oldest-first. -function lttbCacheStore(key, entry) { - lttbCache.delete(key); - lttbCache.set(key, entry); - while (lttbCache.size > LTTB_CACHE_MAX) { - lttbCache.delete(lttbCache.keys().next().value); +function decimCacheStore(key, entry) { + decimCache.delete(key); + decimCache.set(key, entry); + while (decimCache.size > DECIM_CACHE_MAX) { + decimCache.delete(decimCache.keys().next().value); } } -let _lttbWorker = null; +let _decimWorker = null; try { - _lttbWorker = new Worker('lttb-worker.js'); - _lttbWorker.onmessage = function({ data: { id, t, v } }) { - const gen = lttbPending.get(id); - lttbPending.delete(id); - lttbCacheStore(id, { t, v, gen }); + _decimWorker = new Worker('decimate-worker.js'); + _decimWorker.onmessage = function({ data: { id, t, v } }) { + const gen = decimPending.get(id); + decimPending.delete(id); + decimCacheStore(id, { t, v, gen }); // Invalidate and redraw the owning plot. Clearing lastDataGen defeats the // render loop's no-new-data fast path so this fresh result is actually drawn. const plotId = parseInt(id.split(':')[0], 10); const p = plots.find(q => q.id === plotId); if (p) { p.needsRedraw = true; p.lastDataGen = -1; } }; - _lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e); + _decimWorker.onerror = e => console.warn('[decimate-worker] error:', e); } catch(e) { - console.warn('[lttb-worker] unavailable, using sync fallback:', e); + console.warn('[decimate-worker] unavailable, using sync fallback:', e); } -// Submit a LTTB job to the worker (or run sync if worker unavailable). +// Submit a decimation job to the worker (or run sync if worker unavailable). // `gen` identifies the input data behind a key that does not itself change with // the data (rolling mode). A cached entry computed for an older generation is // still returned — stale-while-revalidate — while a fresh job runs. Callers // whose key already encodes the input (zoom ranges) pass no generation. // Returns the cached {t, v} (fresh or stale), or null on the first render. -function lttbAsync(cacheKey, t, v, threshold, gen) { - const cached = lttbCache.get(cacheKey); +function decimateAsync(cacheKey, t, v, threshold, gen) { + const cached = decimCache.get(cacheKey); if (cached && cached.gen === gen) return cached; // fresh — nothing to do // At most one job per key in flight: submitting on every generation change // would let the worker's message queue grow without bound whenever it cannot // keep up with the push rate. - if (!lttbPending.has(cacheKey)) { - lttbPending.set(cacheKey, gen); - if (_lttbWorker) { + if (!decimPending.has(cacheKey)) { + decimPending.set(cacheKey, gen); + if (_decimWorker) { // 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 }, + _decimWorker.postMessage({ id: cacheKey, t: tCopy, v: vCopy, threshold }, [tCopy.buffer, vCopy.buffer]); } else { // Synchronous fallback (worker unavailable). - const result = lttb(t, v, threshold); + const result = decimate(t, v, threshold); result.gen = gen; - lttbCacheStore(cacheKey, result); - lttbPending.delete(cacheKey); + decimCacheStore(cacheKey, result); + decimPending.delete(cacheKey); return result; } } return cached || null; // stale entry, or nothing to draw yet } -// Evict stale LTTB cache entries for a plot (call when zoom range changes). -function lttbCacheEvict(plotId) { +// Evict stale decimation cache entries for a plot (call when zoom range changes). +function decimCacheEvict(plotId) { const prefix = plotId + ':'; - for (const k of [...lttbCache.keys()]) { - if (k.startsWith(prefix)) lttbCache.delete(k); + for (const k of [...decimCache.keys()]) { + if (k.startsWith(prefix)) decimCache.delete(k); } - for (const k of [...lttbPending.keys()]) { - if (k.startsWith(prefix)) lttbPending.delete(k); + for (const k of [...decimPending.keys()]) { + if (k.startsWith(prefix)) decimPending.delete(k); } } -// LTTB decimation — O(n). Returns {t, v} unchanged when len ≤ threshold. -function lttb(t, v, threshold) { +/* Min/max (peak-envelope) decimation — the same algorithm as decimate-worker.js + and minMaxDecimate() in the Go hub, run inline when the worker result is not + ready yet. Splits the range into threshold/2 buckets and keeps each bucket's + smallest and largest sample in the order the two occurred, so a one-sample + spike survives being drawn at 1/1000 of its resolution. */ +function decimate(t, v, threshold) { const len = t.length; - if (len <= threshold) return { t, v }; + if (len <= threshold || threshold < 4) return { t, v }; + const buckets = threshold >> 1; 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; } + let n = 0; + for (let b = 0; b < buckets; b++) { + const lo = Math.floor(b * len / buckets); + const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets); + if (lo >= hi) continue; + let iMin = lo, iMax = lo; + for (let j = lo + 1; j < hi; j++) { + if (v[j] < v[iMin]) iMin = j; + if (v[j] > v[iMax]) iMax = j; } - outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next; + if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; } + outT[n] = t[iMin]; outV[n] = v[iMin]; n++; + if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; } } - return { t: outT, v: outV }; + return { t: outT.subarray(0, n), v: outV.subarray(0, n) }; } /* ════════════════════════════════════════════════════════════════ Hybrid zoom: hi-res data fetched from /api/zoom on demand ════════════════════════════════════════════════════════════════ */ +/* Plot x-ranges are stored relative to the trigger instant while a capture is on + screen, but the hub only knows absolute time — so every range that leaves this + client (and every comparison against a reply) goes through here. */ +function absXRange(range) { + if (!range) return null; + if (!inTrigWindow() || trig.trigTime === null) return range; + return [trig.trigTime + range[0], trig.trigTime + range[1]]; +} + function cancelZoomFetch() { if (_zoomFetchTimer !== null) { clearTimeout(_zoomFetchTimer); _zoomFetchTimer = null; } } @@ -1195,7 +1459,8 @@ async function doZoomFetch(t0, t1) { 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 abs = absXRange(p.xRange); + if (!abs || Math.abs(abs[0] - t0) > 1e-9 || Math.abs(abs[1] - t1) > 1e-9) return; const plotSigs = {}; p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; }); if (Object.keys(plotSigs).length > 0) { @@ -1229,7 +1494,7 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) { if (!masterSd || !masterSd.t.length) return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; - const dec = lttb(masterSd.t, masterSd.v, targetPts); + const dec = decimate(masterSd.t, masterSd.v, targetPts); const sharedT = dec.t; const yArrays = []; for (const key of p.traces) { @@ -1321,7 +1586,7 @@ function fmtTrigTick(u, vals) { // 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; + if (!inTrigWindow()) return; const { ctx, bbox } = u; if (!bbox) return; const x = u.valToPos(0, 'x', true); @@ -1341,16 +1606,18 @@ function drawTriggerMarker(u, p) { 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)) { + // Horizontal threshold line — only on plots that contain the trigger signal. + // The trigger may target one element of an array ("sig[3]"), while traces are + // keyed by the base name, so compare on the base key. + const trigBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : ''; + if (p && trigBase && p.traces.includes(trigBase)) { // Normalise the calibrated threshold to this plot's vscale for the trigger signal. - const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null; + const tvs = sigVScale[p.id + ':' + trigBase]; 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; + threshNorm = (trig.threshold - ofs) / dv; } const y = u.valToPos(threshNorm, 'y', true); if (y >= bbox.top && y <= bbox.top + bbox.height) { @@ -1440,23 +1707,40 @@ function drawActiveSeries(u, p) { 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. + +// Draw offset position markers (right-pointing triangles) on the left edge of +// the plot for each signal. The marker sits at the signal's zero level: +// y_norm = (0 - offset)/div, so signals with different offsets are visually +// separated and dragging the marker adjusts the offset directly. +// Active signal marker is larger and outlined in white. +// +// Unified mode has a single scale for the whole plot, so it gets one neutral +// marker instead of N identical triangles stacked on the same pixel. +function offsetMarkerEntries(p) { + if (p.mode === 'unified') { + const vs = sigVScale[p.id + ':' + UNIFIED_VS_KEY]; + return vs ? [{ key: null, vs, color: '#9399b2' }] : []; + } + return p.traces + .map(key => ({ key, vs: sigVScale[p.id + ':' + key], color: getSigStyle(key).color })) + .filter(e => e.vs); +} + 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; + offsetMarkerEntries(p).forEach(({ key, vs, color }) => { + const dv = vs._resolvedDiv || vs.divValue || 1; + const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); + const yCtr = u.valToPos(-ofs / dv, 'y', true); + const isActive = key !== null && plotActiveSignal[p.id] === key; + const mH = (isActive ? 7 : 5) * dpr; + const mW = (isActive ? 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; + ctx.fillStyle = 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); @@ -1473,8 +1757,6 @@ function drawOffsetMarkers(u, p) { }); } -// 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; @@ -1557,14 +1839,13 @@ function drawCursorLines(u, p) { 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]; + // y_norm = (y_raw - offset) / divValue → y_raw = y_norm * divValue + offset + const vs = sigVScale[vsKeyFor(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; + vReal = vNorm * dv + ofs; } const tc = getSigStyle(key).color; // Diamond marker at intersection @@ -1596,14 +1877,22 @@ function drawCursorLines(u, p) { drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B'); } -// Convert a normalized (division) y value to the active signal's raw units. +// Convert a normalized (division) y value to raw units — the active signal's in +// scope mode, the plot's shared ones in unified mode. function rulerRawValue(p, yNorm) { + if (p.mode === 'unified') { + const uvs = sigVScale[p.id + ':' + UNIFIED_VS_KEY]; + if (!uvs) return null; + const udv = uvs._resolvedDiv != null ? uvs._resolvedDiv : (uvs.divValue || 1); + const uofs = uvs._resolvedOffset != null ? uvs._resolvedOffset : (uvs.offset || 0); + return yNorm * udv + uofs; + } const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null); const vs = key ? sigVScale[p.id + ':' + key] : null; if (!vs) return null; const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1); const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); - return (yNorm - (vs.screenPos || 0)) * dv + ofs; + return yNorm * dv + ofs; } // Draw the horizontal value rulers (called from the draw hook). @@ -1687,8 +1976,17 @@ function makeUPlotOpts(p, inTrigMode) { }); }); - const xVals = inTrigMode ? (u, vals) => fmtTrigTick(u, vals) - : (u, vals) => fmtLiveTick(u, vals); + // The x grid is pinned to 10 scope divisions (see splits below), so on narrow + // plots — a 2x2 layout, or any small tile — the 11 timestamps run into each + // other. Keep every grid line but label only as many of them as fit. + const xVals = (u, vals) => { + const labels = inTrigMode ? fmtTrigTick(u, vals) : fmtLiveTick(u, vals); + const plotW = u.bbox ? u.bbox.width / (devicePixelRatio || 1) : u.width; + const widest = labels.reduce((m, s) => Math.max(m, s.length), 0); + const need = widest * 7 + 12; // ~7 px per char at the axis font, plus a gap + const step = Math.max(1, Math.ceil((labels.length * need) / Math.max(plotW, 1))); + return step === 1 ? labels : labels.map((s, i) => (i % step === 0 ? s : '')); + }; return { width: Math.max(p.div.clientWidth || 100, 50), @@ -1725,7 +2023,21 @@ function makeUPlotOpts(p, inTrigMode) { }, }, { - stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 60, + stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, + // Measure the widest label instead of assuming one fits in 60 px: large + // offsets ("9.410e+8") and signal names in digital mode overflow a fixed + // gutter and get clipped at the canvas edge. + size: (u, values, axisIdx, cycleNum) => { + const axis = u.axes[axisIdx]; + if (cycleNum > 1) return axis._size; + let sz = (axis.ticks && axis.ticks.size || 10) + (axis.gap || 5); + const longest = (values || []).reduce((a, v) => (String(v).length > a.length ? String(v) : a), ''); + if (longest !== '') { + u.ctx.font = axis.font[0]; + sz += u.ctx.measureText(longest).width / (devicePixelRatio || 1); + } + return Math.ceil(Math.max(40, Math.min(140, sz))); + }, splits: () => { if (isBanded && p.traces.length > 0) { const n = p.traces.length, bandH = 8 / n; @@ -1744,13 +2056,16 @@ function makeUPlotOpts(p, inTrigMode) { return k.includes(':') ? k.split(':').slice(1).join(':') : k; }); } + // Unified mode labels the axis from the shared scale, so it needs no + // active signal — the numbers apply to every trace at once. const activeKey = plotActiveSignal[p.id]; - const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null; + const vs = p.mode === 'unified' + ? getVScale(p.id, UNIFIED_VS_KEY) + : (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)); + return _fmtTickVals(vals.map(v => v == null ? null : v * divValue + offset)); }, }, ], @@ -1780,7 +2095,7 @@ function createUPlot(p) { // Destroy previous instance if (p.uplot) { p.uplot.destroy(); p.uplot = null; } - const inTrigMode = trig.enabled && trig.snapshot !== null; + const inTrigMode = inTrigWindow(); const opts = makeUPlotOpts(p, inTrigMode); const data = buildUPlotData(p, inTrigMode); @@ -1788,6 +2103,9 @@ function createUPlot(p) { p.div.querySelectorAll('.uplot').forEach(el => el.remove()); p.uplot = new uPlot(opts, data, p.div); + // Which axis formatter / cursor-sync group this instance was built with, so + // the render loop can rebuild when the plot flips between live and trigger. + p.uplot._trigMode = inTrigMode; // ── Cursor drag: place or drag A/B cursors ────────────────────────────── // - Near an existing cursor line (within CURSOR_SNAP_PX): drag to move it. @@ -1879,7 +2197,9 @@ function createUPlot(p) { // ── 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. + // Dragging moves the marker (the signal's zero level) AND the trace together + // by changing the vscale offset. Auto/range mode is switched to manual on + // drag start (seeded with the resolved V/div) so the drag sticks. p.div.addEventListener('mousedown', e => { if (e.button !== 0 || !p.uplot || !p.uplot.bbox) return; const canvas = p.uplot.ctx.canvas; @@ -1890,37 +2210,53 @@ function createUPlot(p) { 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); + // Find which marker was hit (closest to its zero-level canvas position). + // In unified mode there is exactly one marker and its key is null. + let hit = null, hitDist = Infinity; + offsetMarkerEntries(p).forEach(entry => { + const dv = entry.vs._resolvedDiv || entry.vs.divValue || 1; + const ofs = entry.vs._resolvedOffset != null ? entry.vs._resolvedOffset : (entry.vs.offset || 0); + const yDev = p.uplot.valToPos(-ofs / dv, '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 (dist < 14 && dist < hitDist) { hitDist = dist; hit = entry; } }); - if (!hitKey) return; + if (!hit) return; + const hitKey = hit.key; e.preventDefault(); e.stopPropagation(); - setActiveSig(p.id, hitKey); + if (hitKey !== null) setActiveSig(p.id, hitKey); - const vs = getVScale(p.id, hitKey); + const vs = hit.vs; const startY = e.clientY; - const startScreenPos = vs.screenPos || 0; const overRect = p.uplot.over.getBoundingClientRect(); + // Seed manual mode from the resolved scale so the drag is visible immediately. + if (vs.mode !== 'manual') { + vs.divValue = vs._resolvedDiv || 1; + vs.offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); + vs.mode = 'manual'; + if (hitKey !== null && _vsMenuKey === hitKey) { + document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4)); + document.getElementById('vscale-manual-row').style.display = 'flex'; + document.getElementById('vscale-offset-row').style.display = 'flex'; + } + } + const startOffset = vs.offset != null ? vs.offset : 0; + const dv = vs.divValue || 1; 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. + // Y scale spans 9 divisions over plot height; drag up → trace up. 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)); + vs.offset = startOffset - dNorm * dv; + vs._resolvedOffset = vs.offset; + // Keep "Offset" input in sync if the vscale menu is open for this signal. + if (hitKey !== null && _vsMenuKey === hitKey) { + document.getElementById('vscale-offset').value = parseFloat(vs.offset.toPrecision(6)); } - refreshPlotForKey(hitKey); + if (hitKey !== null) refreshPlotForKey(hitKey); + else p.needsRedraw = true; }; const onUp = () => { document.removeEventListener('mousemove', onMove); @@ -2009,8 +2345,10 @@ function createUPlot(p) { 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. + // Fired, window still filling: draw live data on the final trigger axis. + if (trigFilling()) return buildTrigFillData(p); + + // Trigger enabled but not fired yet (armed): freeze on the last render. if (trig.enabled && !inTrigMode) { if (!p.uplot || !p.uplot.data || !p.uplot.data[0]) return [new Float64Array(0)]; return p.uplot.data; @@ -2059,7 +2397,7 @@ 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) { - const fetched = 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(DECIM_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; } @@ -2089,12 +2427,12 @@ function buildLiveData(p) { // 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 + // needs for a 1200px-wide canvas. Always decimate via the background worker // (stale-while-revalidate: use cached result; fall back to sync on first render). // The rolling-mode key is constant per (plot, signal) — the data generation is // carried separately so the cache holds one entry per plot instead of one per // push tick, which used to grow without bound for the whole session. - const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); + const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); const cacheKey = isRolling ? `${p.id}:${masterKey}:rolling` : `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`; @@ -2104,14 +2442,14 @@ function buildLiveData(p) { sharedT = masterRaw.t; masterV = masterRaw.v; } else { - const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, + const cached = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, isRolling ? _dataGen : undefined); 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); + dec = decimate(masterRaw.t, masterRaw.v, targetPts); } sharedT = dec.t; masterV = dec.v; @@ -2138,15 +2476,35 @@ function buildTrigData(p) { 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); + const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); + + /* The capture frame is the whole window in 20 000 points, so zooming into it + magnifies that decimation instead of resolving detail. When the hub has + answered a zoom for exactly this range — out of the ring while the window is + still in it, out of the disk history afterwards — that reply is the same + samples at the zoomed resolution, so it wins. It is only ever a supplement: + an empty or stale reply falls back to the snapshot, which is always there. */ + const zd = p.xRange ? zoomData[p.id] : null; + const fetched = (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) + ? zd.signals : null; + /* Only when it spans the view. The window rolls out of the ring soon after the + capture (the ring is sized at ~1.2× the window), so a late reply can cover + just the newest sliver of the range — and half a trace at high resolution is + worse than a whole one at low resolution. */ + const edgeTol = (t1 - t0) * 0.02; + const covers = (sd) => sd && sd.t.length > 1 && + sd.t[0] <= t0 + edgeTol && sd.t[sd.t.length - 1] >= t1 - edgeTol; // Slice all traces; pick master by samplingRate first, then sample count const slices = {}; + let usedFetched = false; 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); + const hi = fetched ? fetched[key] : null; + const src = covers(hi) ? hi : trig.snapshot[key]; + if (!src) continue; + if (src === hi) usedFetched = true; + const sl = sliceTypedArrayRange(src.t, src.v, t0, t1); slices[key] = sl; const rate = getKeySamplingRate(key); if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) { @@ -2159,10 +2517,11 @@ function buildTrigData(p) { 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); + // key only needs range + data length. A fetched slice can replace a + // same-length snapshot slice for the same range, so it is tagged separately. + const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}:${usedFetched ? 'hi' : 'snap'}`; + const cachedDec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts); + const dec = cachedDec || decimate(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; @@ -2180,6 +2539,67 @@ function buildTrigData(p) { return [sharedT, ...applyVScaleNorm(p, yArrays)]; } +/* Trace drawn while a fired trigger's window is still filling. Identical + framing to buildTrigData (relative seconds, fixed [-pre, +post] axis) but fed + from the live push buffers, so the waveform sweeps in from the left instead + of the plot sitting frozen until the hub finishes the capture. */ +function buildTrigFillData(p) { + if (p.traces.length === 0) return [new Float64Array(0)]; + + const trigT = trig.trigTime; + const preS = trigPreSec(), postS = trigPostSec(); + const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS; + const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS; + + const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); + + const slices = {}; + let masterKey = p.traces[0], masterCount = -1, masterRate = -1; + for (const key of p.traces) { + const buf = buffers[key]; + if (!buf || buf.size === 0) continue; + const sl = getBufferSliceRange(buf, t0, t1); + 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))]; + + // The window keeps growing, so key the cache on the data generation the same + // way the rolling live path does rather than on the (fixed) range. + let sharedAbsT, masterV; + if (masterRaw.t.length <= targetPts) { + sharedAbsT = masterRaw.t; + masterV = masterRaw.v; + } else { + const cacheKey = `${p.id}:${masterKey}:trigfill`; + const dec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, _dataGen) || + decimate(masterRaw.t, masterRaw.v, targetPts); + sharedAbsT = dec.t; + masterV = dec.v; + } + + const sharedT = new Float64Array(sharedAbsT.length); + for (let i = 0; i < sharedAbsT.length; i++) sharedT[i] = sharedAbsT[i] - trigT; + + 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; } + 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 ════════════════════════════════════════════════════════════════ */ @@ -2211,14 +2631,17 @@ function onZoom(sourcePlotId, min, max) { }); zoomGuard = false; - // Evict stale LTTB cache entries — new zoom range needs fresh decimation. - plots.forEach(p => lttbCacheEvict(p.id)); + // Evict stale decimation cache entries — new zoom range needs fresh decimation. + plots.forEach(p => decimCacheEvict(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); + // Schedule hi-res fetch from ring buffers. A capture is a snapshot the hub + // already decimated to 20 000 pts for the whole window, so zooming into it + // needs the same round trip as live zooming does, not just a bigger axis. + const abs = absXRange([min, max]); + scheduleZoomFetch(abs[0], abs[1]); } // Undo last zoom/pan action @@ -2242,7 +2665,8 @@ function zoomBack() { p.needsRedraw = true; }); zoomGuard = false; - scheduleZoomFetch(prev[0], prev[1]); + const abs = absXRange(prev); + scheduleZoomFetch(abs[0], abs[1]); } } @@ -2252,9 +2676,9 @@ function resetZoom() { 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(); + if (inTrigWindow()) { + const preS = activePreSec(); + const postS = activePostSec(); zoomGuard = true; plots.forEach(p => { p.xRange = null; @@ -2273,7 +2697,7 @@ function resetZoom() { // Fit x-axis to all data currently in buffers (or full trigger snapshot) function zoomFit() { - if (trig.enabled && trig.snapshot) { + if (inTrigWindow()) { resetZoom(); // "Fit" in trigger mode = show full trigger window return; } @@ -2323,22 +2747,35 @@ function updateCursorBtnVisibility() { document.getElementById('btn-cursor').style.display = ''; } +/* Place cursors A/B at 25 %/75 % of what is currently on screen. + + Cursor positions are absolute — Unix seconds live, seconds from the trigger + under one — so a zoom, a pan or a new capture can leave them outside the + viewport entirely, with no way to get them back: they are dragged by grabbing + their line, and an off-screen line cannot be grabbed. */ +function resetCursors() { + const refPlot = plots.find(p => p.uplot); + if (!refPlot) return; + const { min, max } = refPlot.uplot.scales.x; + if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return; + const span = max - min; + cursors.tA = min + span * 0.25; + cursors.tB = min + span * 0.75; + updateCursorReadout(); + 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'); + // Reset is only meaningful while the cursors are drawn. + document.getElementById('btn-cursor-reset').style.display = + cursors.mode === 'on' ? '' : 'none'; 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; - } - } + // Auto-place on first use; afterwards the positions are the user's. + if (cursors.tA === null && cursors.tB === null) resetCursors(); updateCursorReadout(); document.getElementById('cursor-readout').classList.add('visible'); } else { @@ -2347,6 +2784,8 @@ document.getElementById('btn-cursor').addEventListener('click', () => { cursorsDirty = true; }); +document.getElementById('btn-cursor-reset').addEventListener('click', resetCursors); + document.getElementById('btn-ruler').addEventListener('click', () => { rulers.mode = rulers.mode === 'off' ? 'on' : 'off'; const btn = document.getElementById('btn-ruler'); @@ -2374,13 +2813,12 @@ function getValueAtCursor(p, t) { 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 + // Un-normalize: y_norm = (y_raw - offset) / divValue 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; + return vNorm * dv + ofs; } // Update per-plot cursor value readouts (A, B, ΔV) for all plots. @@ -2411,7 +2849,7 @@ function rawFromNorm(p, key, vNorm) { if (!vs) return vNorm; const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1); const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); - return (vNorm - (vs.screenPos || 0)) * dv + ofs; + return vNorm * dv + ofs; } function hideHoverReadout() { @@ -2429,7 +2867,7 @@ function showHoverReadout(p, e) { const t = min + pct * (max - min); const span = Math.abs(max - min); - const tStr = (trig.enabled && trig.snapshot) ? fmtDuration(t, span, true) : fmtLiveTime(t, span); + const tStr = inTrigWindow() ? fmtDuration(t, span, true) : fmtLiveTime(t, span); let html = '
' + escHtml(tStr) + '
'; p.traces.forEach((key, idx) => { const vNorm = interpAtTime(p.uplot, idx + 1, t); @@ -2491,7 +2929,7 @@ function updateCursorReadout() { // 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); + if (inTrigWindow()) return fmtDuration(v, span, true); return fmtLiveTime(v, span); }; @@ -2573,6 +3011,13 @@ document.getElementById('trig-pre').addEventListener('input', e => { document.getElementById('trig-pre-val').textContent = trig.prePercent + '%'; sendTrigConfig(); }); +document.getElementById('trig-holdoff').addEventListener('change', e => { + let v = parseFloat(e.target.value); + if (!isFinite(v)) v = 0.2; + trig.holdoffSec = Math.max(0, Math.min(60, v)); + e.target.value = trig.holdoffSec; + sendTrigConfig(); +}); document.getElementById('trig-mode').addEventListener('change', e => { trig.mode = e.target.value; sendTrigConfig(); @@ -2673,27 +3118,13 @@ function buildSidebar() { grp.appendChild(hdr); sigs.forEach(sig => { - const n = numElements(sig), temporal = isTemporal(sig); + const n = numElements(sig); const typeName = _typeNames[sig.typeCode] || '?'; const globalKey = prefix + sig.name; - const effUnit = unitForKey(globalKey); - if (n === 1 || temporal) { - grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, effUnit)); - } else { - const group = document.createElement('div'); group.className = 'array-group'; - const header = document.createElement('div'); header.className = 'array-header'; - header.innerHTML = '' + escHtml(sig.name) + '' - + (effUnit ? '' + escHtml(effUnit) + '' : '') - + '[' + n + '] ' + typeName + ''; - 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, effUnit); - child.className = 'array-child'; children.appendChild(child); - } - group.appendChild(header); group.appendChild(children); grp.appendChild(group); - } + // One row per signal, array or not: the hubs stream every array under the + // base name, so per-element rows only ever produced empty traces. + grp.appendChild(makeDraggable(globalKey, sig.name, + n > 1 ? '[' + n + '] ' + typeName : typeName, unitForKey(globalKey))); }); list.appendChild(grp); @@ -2808,6 +3239,7 @@ function showPlotCfg(plotId) { if (_pcfgOpenId != null && _pcfgOpenId !== plotId) hidePlotCfg(); _pcfgOpenId = plotId; document.getElementById('pcfg-' + plotId).style.display = 'block'; + syncPlotCfgVScale(plotId); } function hidePlotCfg() { @@ -2817,6 +3249,45 @@ function hidePlotCfg() { _pcfgOpenId = null; } +// Repaint the plot-level Y-scale controls from the plot's shared scale. Only +// meaningful in unified mode; the group stays hidden otherwise. +function syncPlotCfgVScale(plotId) { + const bar = document.getElementById('pcfg-' + plotId); + const p = plots.find(q => q.id === plotId); + if (!bar || !p) return; + const group = bar.querySelector('.pcfg-vs-group'); + if (!group) return; + + const isUnified = p.mode === 'unified'; + group.style.display = isUnified ? 'flex' : 'none'; + if (!isUnified) return; + + const vs = getVScale(plotId, UNIFIED_VS_KEY); + group.querySelectorAll('.pcfg-vs-mode .ctx-btn').forEach(btn => + btn.classList.toggle('active', btn.dataset.mode === vs.mode)); + + // Range is only meaningful if at least one trace declares one. + const rangeBtn = group.querySelector('.pcfg-vs-mode [data-mode="range"]'); + if (rangeBtn) { + const hasRange = p.traces.some(k => { + const meta = findSignalMeta(k); + return meta && meta.rangeMin != null && meta.rangeMax != null; + }); + rangeBtn.disabled = !hasRange; + rangeBtn.title = hasRange ? '' : 'No range defined for any signal in this plot'; + } + + const isManual = vs.mode === 'manual'; + group.querySelector('.pcfg-vs-manual').style.display = isManual ? 'flex' : 'none'; + const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1); + const ofs = isManual ? (vs.offset || 0) : (vs._resolvedOffset || 0); + const dvEl = group.querySelector('.pcfg-vs-vdiv'); + const ofsEl = group.querySelector('.pcfg-vs-offset'); + const focused = document.activeElement; + if (focused !== dvEl) dvEl.value = dv != null ? parseFloat(dv.toPrecision(4)) : 1; + if (focused !== ofsEl) ofsEl.value = parseFloat(ofs.toPrecision(6)); +} + function initPlotCfgBar(plotId, p) { const bar = document.getElementById('pcfg-' + plotId); if (!bar) return; @@ -2835,11 +3306,49 @@ function initPlotCfgBar(plotId, p) { if (p.mode === newMode) return; p.mode = newMode; bar.querySelectorAll('.pcfg-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === newMode)); + // Entering or leaving unified mode swaps which vscale each badge reads, + // and moves the scale controls between this bar and the signal panel. + p.traces.forEach(key => _updateBadgeVScaleInfo(plotId, key)); + syncPlotCfgVScale(plotId); + if (_vsMenuPlotId === plotId && _vsMenuKey) showVScaleMenu(_vsMenuKey, plotId); createUPlot(p); p.needsRedraw = true; }); }); + const group = bar.querySelector('.pcfg-vs-group'); + if (group) { + group.querySelectorAll('.pcfg-vs-mode .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + const vs = getVScale(plotId, UNIFIED_VS_KEY); + // Seed manual from whatever is on screen, so switching to Manual does + // not jump the trace before the user has typed anything. + if (btn.dataset.mode === 'manual' && vs.mode !== 'manual') { + vs.divValue = vs._resolvedDiv != null ? vs._resolvedDiv : 1; + vs.offset = vs._resolvedOffset != null ? vs._resolvedOffset : 0; + } + vs.mode = btn.dataset.mode; + syncPlotCfgVScale(plotId); + p.needsRedraw = true; + }); + }); + group.querySelector('.pcfg-vs-vdiv').addEventListener('input', e => { + const v = parseFloat(e.target.value); + if (!isFinite(v) || v === 0) return; + const vs = getVScale(plotId, UNIFIED_VS_KEY); + vs.divValue = Math.abs(v); vs.mode = 'manual'; + p.traces.forEach(key => _updateBadgeVScaleInfo(plotId, key)); + p.needsRedraw = true; + }); + group.querySelector('.pcfg-vs-offset').addEventListener('input', e => { + const v = parseFloat(e.target.value); + if (!isFinite(v)) return; + const vs = getVScale(plotId, UNIFIED_VS_KEY); + vs.offset = v; vs.mode = 'manual'; + p.needsRedraw = true; + }); + } + bar.querySelector('.pcfg-close-btn').addEventListener('click', hidePlotCfg); } @@ -3001,7 +3510,7 @@ async function exportAllCSV() { btn.textContent = '⏳ Downloading…'; btn.disabled = true; - // Fetch full-resolution ring data (n=0 → no LTTB decimation). + // Fetch full-resolution ring data (n=0 → no decimation). let ringSignals = null; try { ringSignals = await wsZoomRequest(t0, t1, 0, keys); @@ -3094,10 +3603,28 @@ function addPlot() { Mode
- + +
+ + @@ -3117,7 +3644,7 @@ function addPlot() { 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 }; + const p = { id, title: 'Plot ' + id, mode: 'unified', traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 }; plots.push(p); // Wire config toolbar @@ -3216,7 +3743,7 @@ function deletePlot(plotId) { const p = plots[idx]; if (p.ro) p.ro.disconnect(); if (p.uplot) p.uplot.destroy(); - lttbCacheEvict(plotId); + decimCacheEvict(plotId); plots.splice(idx, 1); document.querySelector('[data-plot-id="' + plotId + '"]').remove(); } @@ -3285,9 +3812,11 @@ function renderDirtyPlots() { // 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) { + // A filling trigger window is redrawn for the same reason: new samples keep + // arriving, they just land on a fixed axis instead of a moving one. + if ((!trig.enabled || trigFilling()) && !globalPause) { plots.forEach(p => { - if (!p.uplot || p.xRange || p.traces.length === 0) return; + if (!p.uplot || (p.xRange && !trigFilling()) || p.traces.length === 0) return; p.needsRedraw = true; }); } @@ -3295,7 +3824,12 @@ function renderDirtyPlots() { plots.forEach(p => { if (!p.needsRedraw || !p.uplot || p.traces.length === 0) return; - const inTrigModeNow = trig.enabled && trig.snapshot !== null; + const inTrigModeNow = inTrigWindow(); + // The x tick formatter and the cursor-sync group are baked into the uPlot + // options at construction. A plot built in live mode therefore keeps + // formatting the capture's relative seconds as absolute wall-clock time + // (and vice-versa), so rebuild whenever the mode flips. + if (p.uplot._trigMode !== inTrigModeNow) createUPlot(p); const isRolling = !trig.enabled && !p.xRange; // Fast path: rolling-window plot with no new data — just shift viewport @@ -3326,8 +3860,8 @@ function renderDirtyPlots() { 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(); + const preS = activePreSec(); + const postS = activePostSec(); p.uplot.setScale('x', { min: p.xRange ? p.xRange[0] : -preS, max: p.xRange ? p.xRange[1] : postS @@ -3364,16 +3898,15 @@ document.getElementById('btn-pause-global').addEventListener('click', () => { document.getElementById('window-select').addEventListener('change', e => { windowSec = parseFloat(e.target.value); - // Grow any buffers that can't hold the full new window for their signal's rate. + sendWindow(); + // Grow any buffer that cannot reach back over the new window. It fills from + // live data, so widening the window shows the past arriving rather than + // already there — but the buffer must at least stop discarding it. 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); - } + buffers[key] = growBufferForWindow(buffers[key], windowSec); }); - // Evict rolling-mode LTTB cache — window size change invalidates all cached results. - plots.forEach(p => lttbCacheEvict(p.id)); + // Evict rolling-mode decimation cache — window size change invalidates all cached results. + plots.forEach(p => decimCacheEvict(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; }); @@ -3485,11 +4018,11 @@ function renderCfgStatus(el) { } function onConfigAck(msg) { - const what = msg.type === 'configSaved' ? 'Saved' : 'Reloaded'; + const saved = msg.type === 'configSaved'; if (msg.ok) { - _cfgStatus = { ok: true, text: what + ': ' + (msg.path || 'config file') }; + _cfgStatus = { ok: true, text: (saved ? 'Saved: ' : 'Reloaded: ') + (msg.path || 'config file') }; } else { - _cfgStatus = { ok: false, text: what + ' failed: ' + (msg.error || 'unknown error') }; + _cfgStatus = { ok: false, text: (saved ? 'Save' : 'Reload') + ' failed: ' + (msg.error || 'unknown error') }; } const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el); @@ -3625,37 +4158,41 @@ function showVScaleMenu(key, plotId) { _vsMenuKey = key; _vsMenuPlotId = plotId; const menu = document.getElementById('vscale-menu'); + const p = plots.find(q => q.id === plotId); + // Unified mode has a single scale for the whole plot, so it is edited from + // the plot config bar; this per-signal panel keeps only the calibration. + const isUnified = !!p && p.mode === 'unified'; const vs = getVScale(_vsMenuPlotId, key); const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key; + document.getElementById('vscale-menu-title').textContent = + isUnified ? 'Signal' : 'V-Scale'; document.getElementById('vscale-menu-key').textContent = displayName; + document.getElementById('vscale-mode-btns').style.display = isUnified ? 'none' : 'flex'; + 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. + // Disable Range button when the signal it applies to 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; + 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'; + const isManual = !isUnified && vs.mode === 'manual'; document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none'; document.getElementById('vscale-offset-row').style.display = isManual ? 'flex' : 'none'; - document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none'; - // Pre-fill V/div and Offset with the resolved or stored values; Position - // always shows the current screenPos. + // Pre-fill V/div and Offset with the resolved or stored values. const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1); const ofs = isManual ? (vs.offset || 0) : (vs._resolvedOffset || 0); document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1; document.getElementById('vscale-offset').value = parseFloat(ofs.toPrecision(6)); - 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) { @@ -3735,13 +4272,13 @@ function initVScaleMenu() { 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. + // Seed V/div and Offset from currently resolved values. vs.divValue = vs._resolvedDiv || 1; vs.offset = vs._resolvedOffset || 0; // raw value at screen centre document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4)); document.getElementById('vscale-offset').value = parseFloat(vs.offset.toPrecision(6)); - 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')); @@ -3749,7 +4286,7 @@ function initVScaleMenu() { const isManual = vs.mode === 'manual'; document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none'; document.getElementById('vscale-offset-row').style.display = isManual ? 'flex' : 'none'; - document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none'; + refreshPlotForKey(_vsMenuKey); }); }); @@ -3759,9 +4296,8 @@ function initVScaleMenu() { vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30); refreshPlotForKey(_vsMenuKey); }); - // "Offset" is the raw value shown at screen centre. Unlike Position (which - // is clamped to the ±4 visible divisions) it is unbounded, so a signal can be - // referenced to a level far outside the currently plotted range. + // "Offset" is the raw value shown at screen centre. It is unbounded, so a + // signal can be referenced to a level far outside the currently plotted range. document.getElementById('vscale-offset').addEventListener('input', e => { if (!_vsMenuKey) return; const vs = getVScale(_vsMenuPlotId, _vsMenuKey); @@ -3769,13 +4305,6 @@ function initVScaleMenu() { vs.offset = isFinite(v) ? v : 0; 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', () => { @@ -4054,6 +4583,9 @@ initSignalMenu(); if (cb) cb.checked = localStorage.getItem('udpscope.monotonic') === '1'; } document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV); +document.getElementById('history-badge').addEventListener('click', toggleHistoryPanel); +document.getElementById('btn-hist-cancel').addEventListener('click', toggleHistoryPanel); +document.getElementById('btn-hist-apply').addEventListener('click', applyHistoryBudget); document.getElementById('btn-stats').addEventListener('click', toggleStats); document.getElementById('btn-stats-close').addEventListener('click', toggleStats); document.getElementById('stats-source-sel').addEventListener('change', e => { diff --git a/Client/udpstreamer/static/decimate-worker.js b/Client/udpstreamer/static/decimate-worker.js new file mode 100644 index 0000000..df422cc --- /dev/null +++ b/Client/udpstreamer/static/decimate-worker.js @@ -0,0 +1,51 @@ +'use strict'; +// Min/max (peak-envelope) decimation — O(n). Runs off-main-thread to avoid +// blocking the render loop. +// +// The range is split into threshold/2 equal buckets and each contributes its +// smallest and largest sample, in the order the two occurred — the way an +// oscilloscope draws a trace it cannot show pixel-for-pixel. +// +// This replaced LTTB, which picks the sample forming the largest triangle with +// its neighbours: a plausible-looking shape, but it silently drops a one-sample +// spike whenever a smoother neighbour scores higher — exactly the sample worth +// looking at. The envelope cannot drop it, because a spike is by definition its +// bucket's min or max. Every output point is a real sample at its real +// timestamp; nothing is interpolated or averaged. +// +// Kept identical to minMaxDecimate() in Common/Client/go/wshub/hub.go and to +// decimate() in app.js, so a trace looks the same whichever thinned it. +function decimate(t, v, threshold) { + const len = t.length; + if (len <= threshold || threshold < 4) { + // Copy to new arrays so we can transfer them back without detaching the input. + return { t: new Float64Array(t), v: new Float64Array(v) }; + } + const buckets = threshold >> 1; + const outT = new Float64Array(threshold); + const outV = new Float64Array(threshold); + let n = 0; + for (let b = 0; b < buckets; b++) { + const lo = Math.floor(b * len / buckets); + const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets); + if (lo >= hi) continue; + let iMin = lo, iMax = lo; + for (let j = lo + 1; j < hi; j++) { + if (v[j] < v[iMin]) iMin = j; + if (v[j] > v[iMax]) iMax = j; + } + // Emit in time order so the result plots as one ascending trace. + if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; } + outT[n] = t[iMin]; outV[n] = v[iMin]; n++; + // A bucket whose samples are all equal has one extreme, not two. + if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; } + } + // slice() so the transferred buffers are exactly the used length. + return { t: outT.slice(0, n), v: outV.slice(0, n) }; +} + +self.onmessage = function({ data: { id, t, v, threshold } }) { + const result = decimate(t, v, threshold); + // Transfer the output buffers back to the main thread zero-copy. + self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]); +}; diff --git a/Client/udpstreamer/static/index.html b/Client/udpstreamer/static/index.html index 9ea64b9..bdce18e 100644 --- a/Client/udpstreamer/static/index.html +++ b/Client/udpstreamer/static/index.html @@ -30,11 +30,14 @@ Window: + @@ -43,7 +46,8 @@ @@ -70,10 +74,19 @@
Window
@@ -83,6 +96,11 @@ 20%
+
+ Holdoff + + s +
Mode + MPts/signal +
+
+ The budget buys resolution, not duration: a signal too fast to store + sample-for-sample is archived as a min/max envelope wide enough to fit, + so the configured window is always covered. +
+
+
Applying re-creates the history files — archived data is lost.
+
+ + +
+