From 617b5bd712a6c58347c4c8e925869c5e7f5f8da2 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 29 May 2026 13:29:59 +0200 Subject: [PATCH] Initial release --- .gitignore | 18 + ARCHITECTURE.md | 240 + Client/debugger/go.mod | 12 + Client/debugger/go.sum | 4 + Client/debugger/main.go | 66 + Client/debugger/martecontrol.go | 895 ++++ Client/debugger/static/app.js | 4629 +++++++++++++++++ Client/debugger/static/index.html | 514 ++ Client/debugger/static/lttb-worker.js | 39 + Client/debugger/static/style.css | 711 +++ Client/debugger/static/uPlot.iife.min.js | 2 + Client/debugger/static/uPlot.min.css | 1 + Client/debugger/static/worker.js | 283 + Client/udpstreamer/go.mod | 12 + Client/udpstreamer/go.sum | 4 + Client/udpstreamer/main.go | 66 + Client/udpstreamer/static/app.js | 3456 ++++++++++++ Client/udpstreamer/static/favicon.svg | 5 + Client/udpstreamer/static/index.html | 203 + Client/udpstreamer/static/lttb-worker.js | 39 + Client/udpstreamer/static/style.css | 527 ++ Client/udpstreamer/static/uPlot.iife.min.js | 2 + Client/udpstreamer/static/uPlot.min.css | 1 + Client/udpstreamer/static/worker.js | 283 + Common/Client/go/go.mod | 7 + Common/Client/go/go.sum | 4 + Common/Client/go/udpsprotocol/protocol.go | 383 ++ Common/Client/go/udpsprotocol/reassembler.go | 105 + Common/Client/go/wshub/hub.go | 903 ++++ Common/Client/go/wshub/ringbuf.go | 88 + Common/Client/go/wshub/sources.go | 483 ++ Common/Client/go/wshub/stats.go | 155 + Common/UDP/UDPSProtocol.h | 234 + Docs/DebugService.md | 241 + Docs/Protocol.md | 208 + Docs/SineArrayGAM.md | 104 + Docs/Tutorial.md | 493 ++ Docs/UDPStreamer.md | 200 + Docs/WebUI.md | 300 ++ Makefile.gcc | 32 + Makefile.inc | 7 + README.md | 228 + Source/Components/DataSources/Makefile.gcc | 7 + Source/Components/DataSources/Makefile.inc | 1 + .../DataSources/UDPStreamer/Makefile.gcc | 25 + .../DataSources/UDPStreamer/Makefile.inc | 56 + .../DataSources/UDPStreamer/UDPStreamer.cpp | 1747 +++++++ .../DataSources/UDPStreamer/UDPStreamer.h | 472 ++ .../DataSources/UDPStreamer/depends.x86-linux | 142 + .../UDPStreamer/dependsRaw.x86-linux | 142 + Source/Components/GAMs/Makefile.gcc | 9 + Source/Components/GAMs/Makefile.inc | 1 + .../Components/GAMs/SineArrayGAM/Makefile.gcc | 25 + .../Components/GAMs/SineArrayGAM/Makefile.inc | 52 + .../GAMs/SineArrayGAM/SineArrayGAM.cpp | 118 + .../GAMs/SineArrayGAM/SineArrayGAM.h | 105 + .../GAMs/SineArrayGAM/depends.x86-linux | 113 + .../GAMs/SineArrayGAM/dependsRaw.x86-linux | 113 + .../Components/GAMs/TimeArrayGAM/Makefile.gcc | 1 + .../Components/GAMs/TimeArrayGAM/Makefile.inc | 28 + .../GAMs/TimeArrayGAM/TimeArrayGAM.cpp | 108 + .../GAMs/TimeArrayGAM/TimeArrayGAM.h | 64 + .../GAMs/TimeArrayGAM/depends.x86-linux | 113 + .../GAMs/TimeArrayGAM/dependsRaw.x86-linux | 113 + .../DebugService/DebugBrokerWrapper.h | 590 +++ .../Interfaces/DebugService/DebugCore.h | 188 + .../Interfaces/DebugService/DebugService.cpp | 618 +++ .../Interfaces/DebugService/DebugService.h | 183 + .../DebugService/DebugServiceBase.cpp | 1719 ++++++ .../DebugService/DebugServiceBase.h | 214 + .../Interfaces/DebugService/DebugServiceI.h | 188 + .../Interfaces/DebugService/Makefile.gcc | 1 + .../Interfaces/DebugService/Makefile.inc | 36 + Source/Components/Interfaces/Makefile.gcc | 9 + Source/Components/Interfaces/Makefile.inc | 1 + .../Interfaces/TCPLogger/Makefile.gcc | 1 + .../Interfaces/TCPLogger/Makefile.inc | 31 + .../Interfaces/TCPLogger/TcpLogger.cpp | 163 + .../Interfaces/TCPLogger/TcpLogger.h | 67 + Source/Components/Makefile.gcc | 11 + Source/Components/Makefile.inc | 1 + .../DataSources/UDPStreamer/Makefile.gcc | 1 + .../DataSources/UDPStreamer/Makefile.inc | 58 + .../UDPStreamer/UDPStreamerGTest.cpp | 227 + .../UDPStreamer/UDPStreamerTest.cpp | 1835 +++++++ .../DataSources/UDPStreamer/UDPStreamerTest.h | 239 + Test/Configurations/TestApp.cfg | 569 ++ Test/Configurations/combined_test.cfg | 384 ++ Test/Configurations/debug_test.cfg | 189 + Test/Configurations/webdebug_test.cfg | 186 + Test/GTest/MainGTest.cpp | 29 + Test/GTest/Makefile.gcc | 34 + Test/GTest/Makefile.inc | 55 + Test/GTest/depends.x86-linux | 101 + Test/GTest/dependsRaw.x86-linux | 101 + Test/Integration/ConfigCommandTest.cpp | 161 + Test/Integration/IntegrationTests.cpp | 252 + Test/Integration/Makefile.gcc | 1 + Test/Integration/Makefile.inc | 43 + Test/Integration/MessageCommandTest.cpp | 72 + Test/Integration/SchedulerTest.cpp | 253 + Test/Integration/TestCommon.cpp | 74 + Test/Integration/TestCommon.h | 13 + Test/Integration/TraceTest.cpp | 80 + Test/Integration/TreeCommandTest.cpp | 178 + Test/Integration/ValidationTest.cpp | 161 + Test/Makefile.gcc | 9 + Test/Makefile.inc | 1 + env.sh | 18 + run_combined_test.sh | 147 + 110 files changed, 29234 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 Client/debugger/go.mod create mode 100644 Client/debugger/go.sum create mode 100644 Client/debugger/main.go create mode 100644 Client/debugger/martecontrol.go create mode 100644 Client/debugger/static/app.js create mode 100644 Client/debugger/static/index.html create mode 100644 Client/debugger/static/lttb-worker.js create mode 100644 Client/debugger/static/style.css create mode 100644 Client/debugger/static/uPlot.iife.min.js create mode 100644 Client/debugger/static/uPlot.min.css create mode 100644 Client/debugger/static/worker.js create mode 100644 Client/udpstreamer/go.mod create mode 100644 Client/udpstreamer/go.sum create mode 100644 Client/udpstreamer/main.go create mode 100644 Client/udpstreamer/static/app.js create mode 100644 Client/udpstreamer/static/favicon.svg create mode 100644 Client/udpstreamer/static/index.html create mode 100644 Client/udpstreamer/static/lttb-worker.js create mode 100644 Client/udpstreamer/static/style.css create mode 100644 Client/udpstreamer/static/uPlot.iife.min.js create mode 100644 Client/udpstreamer/static/uPlot.min.css create mode 100644 Client/udpstreamer/static/worker.js create mode 100644 Common/Client/go/go.mod create mode 100644 Common/Client/go/go.sum create mode 100644 Common/Client/go/udpsprotocol/protocol.go create mode 100644 Common/Client/go/udpsprotocol/reassembler.go create mode 100644 Common/Client/go/wshub/hub.go create mode 100644 Common/Client/go/wshub/ringbuf.go create mode 100644 Common/Client/go/wshub/sources.go create mode 100644 Common/Client/go/wshub/stats.go create mode 100644 Common/UDP/UDPSProtocol.h create mode 100644 Docs/DebugService.md create mode 100644 Docs/Protocol.md create mode 100644 Docs/SineArrayGAM.md create mode 100644 Docs/Tutorial.md create mode 100644 Docs/UDPStreamer.md create mode 100644 Docs/WebUI.md create mode 100644 Makefile.gcc create mode 100644 Makefile.inc create mode 100644 README.md create mode 100644 Source/Components/DataSources/Makefile.gcc create mode 100644 Source/Components/DataSources/Makefile.inc create mode 100644 Source/Components/DataSources/UDPStreamer/Makefile.gcc create mode 100644 Source/Components/DataSources/UDPStreamer/Makefile.inc create mode 100644 Source/Components/DataSources/UDPStreamer/UDPStreamer.cpp create mode 100644 Source/Components/DataSources/UDPStreamer/UDPStreamer.h create mode 100644 Source/Components/DataSources/UDPStreamer/depends.x86-linux create mode 100644 Source/Components/DataSources/UDPStreamer/dependsRaw.x86-linux create mode 100644 Source/Components/GAMs/Makefile.gcc create mode 100644 Source/Components/GAMs/Makefile.inc create mode 100644 Source/Components/GAMs/SineArrayGAM/Makefile.gcc create mode 100644 Source/Components/GAMs/SineArrayGAM/Makefile.inc create mode 100644 Source/Components/GAMs/SineArrayGAM/SineArrayGAM.cpp create mode 100644 Source/Components/GAMs/SineArrayGAM/SineArrayGAM.h create mode 100644 Source/Components/GAMs/SineArrayGAM/depends.x86-linux create mode 100644 Source/Components/GAMs/SineArrayGAM/dependsRaw.x86-linux create mode 100644 Source/Components/GAMs/TimeArrayGAM/Makefile.gcc create mode 100644 Source/Components/GAMs/TimeArrayGAM/Makefile.inc create mode 100644 Source/Components/GAMs/TimeArrayGAM/TimeArrayGAM.cpp create mode 100644 Source/Components/GAMs/TimeArrayGAM/TimeArrayGAM.h create mode 100644 Source/Components/GAMs/TimeArrayGAM/depends.x86-linux create mode 100644 Source/Components/GAMs/TimeArrayGAM/dependsRaw.x86-linux create mode 100644 Source/Components/Interfaces/DebugService/DebugBrokerWrapper.h create mode 100644 Source/Components/Interfaces/DebugService/DebugCore.h create mode 100644 Source/Components/Interfaces/DebugService/DebugService.cpp create mode 100644 Source/Components/Interfaces/DebugService/DebugService.h create mode 100644 Source/Components/Interfaces/DebugService/DebugServiceBase.cpp create mode 100644 Source/Components/Interfaces/DebugService/DebugServiceBase.h create mode 100644 Source/Components/Interfaces/DebugService/DebugServiceI.h create mode 100644 Source/Components/Interfaces/DebugService/Makefile.gcc create mode 100644 Source/Components/Interfaces/DebugService/Makefile.inc create mode 100644 Source/Components/Interfaces/Makefile.gcc create mode 100644 Source/Components/Interfaces/Makefile.inc create mode 100644 Source/Components/Interfaces/TCPLogger/Makefile.gcc create mode 100644 Source/Components/Interfaces/TCPLogger/Makefile.inc create mode 100644 Source/Components/Interfaces/TCPLogger/TcpLogger.cpp create mode 100644 Source/Components/Interfaces/TCPLogger/TcpLogger.h create mode 100644 Source/Components/Makefile.gcc create mode 100644 Source/Components/Makefile.inc create mode 100644 Test/Components/DataSources/UDPStreamer/Makefile.gcc create mode 100644 Test/Components/DataSources/UDPStreamer/Makefile.inc create mode 100644 Test/Components/DataSources/UDPStreamer/UDPStreamerGTest.cpp create mode 100644 Test/Components/DataSources/UDPStreamer/UDPStreamerTest.cpp create mode 100644 Test/Components/DataSources/UDPStreamer/UDPStreamerTest.h create mode 100644 Test/Configurations/TestApp.cfg create mode 100644 Test/Configurations/combined_test.cfg create mode 100644 Test/Configurations/debug_test.cfg create mode 100644 Test/Configurations/webdebug_test.cfg create mode 100644 Test/GTest/MainGTest.cpp create mode 100644 Test/GTest/Makefile.gcc create mode 100644 Test/GTest/Makefile.inc create mode 100644 Test/GTest/depends.x86-linux create mode 100644 Test/GTest/dependsRaw.x86-linux create mode 100644 Test/Integration/ConfigCommandTest.cpp create mode 100644 Test/Integration/IntegrationTests.cpp create mode 100644 Test/Integration/Makefile.gcc create mode 100644 Test/Integration/Makefile.inc create mode 100644 Test/Integration/MessageCommandTest.cpp create mode 100644 Test/Integration/SchedulerTest.cpp create mode 100644 Test/Integration/TestCommon.cpp create mode 100644 Test/Integration/TestCommon.h create mode 100644 Test/Integration/TraceTest.cpp create mode 100644 Test/Integration/TreeCommandTest.cpp create mode 100644 Test/Integration/ValidationTest.cpp create mode 100644 Test/Makefile.gcc create mode 100644 Test/Makefile.inc create mode 100644 env.sh create mode 100755 run_combined_test.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6e734d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +Build/ +dependency/ +*.o +*.a +*.so +*.d +compile_commands.json +*.gcov +*.gcda +*.gcno + +# Go binaries +Client/debugger/marte2debugger +Client/udpstreamer/udpstreamer-webui +Client/*/bin/ + +# Standalone webui (integrated into Client/udpstreamer) +udp_standalone_webui/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b74b7cc --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,240 @@ +# Architecture + +This document describes the internal architecture of the MARTe2 Integrated Components. + +--- + +## 1. Repository Overview + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ MARTe2 Application │ +│ ┌──────────┐ ┌───────────────────────┐ ┌──────────────────┐ │ +│ │ GAM(s) │ │ DebugBrokerWrapper │ │ FastScheduler │ │ +│ │ │◄──│ (Registry-patched) │ │ (unmodified) │ │ +│ └──────────┘ └──────────┬────────────┘ └──────────────────┘ │ +│ │ RT-path API │ +└────────────────────────────┼───────────────────────────────────────┘ + │ + ┌────────▼────────┐ + │ DebugServiceI │ ← Abstract singleton interface + └────────┬────────┘ + ┌────────▼────────┐ + │ DebugService │ + │ TCP/UDP │ + └────────┬────────┘ + ┌───────────────┼──────────────┐ + │ │ │ + TCP 8080 UDP 8081 TCP 8082 + (commands) (telemetry) (TcpLogger) + │ │ + ┌────────▼───────────────▼──────┐ + │ Client/debugger │ + │ Go web client (browser) │ + └───────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────────┐ +│ MARTe2 Application │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ UDPStreamer DataSource │ │ +│ │ - Receives signals from GAMs via IOGAM │ │ +│ │ - Serialises to UDPS binary protocol │ │ +│ │ - Manages per-client sessions │ │ +│ └──────────────────────┬──────────────────────┘ │ +└─────────────────────────┼──────────────────────────────────────────┘ + UDP 44500 + │ + ┌───────────▼──────────────┐ + │ Common/Client/go │ + │ udpsprotocol package │ + │ (decode CONFIG+DATA) │ + └───────────┬──────────────┘ + │ WebSocket + ┌───────────▼──────────────┐ + │ Browser oscilloscope │ + │ (Chart.js plots) │ + └──────────────────────────┘ +``` + +--- + +## 2. UDPS Binary Protocol + +Defined in `Common/UDP/UDPSProtocol.h` and shared between: + +- `UDPStreamer` (C++ producer) +- `DebugService` (C++ producer for trace packets) +- `Common/Client/go/udpsprotocol` (Go decoder) + +### Packet Header (17 bytes, little-endian, packed) + +| Offset | Size | Type | Field | Description | +|---|---|---|---|---| +| 0 | 4 | uint32 | `magic` | Always `0x53504455` ('UDPS' LE) | +| 4 | 1 | uint8 | `type` | Packet type (DATA=0, CONFIG=1, ACK=2, CONNECT=3, DISCONNECT=4) | +| 5 | 4 | uint32 | `counter` | Per-update sequence number (same across all fragments) | +| 9 | 2 | uint16 | `fragmentIdx` | 0-based fragment index | +| 11 | 2 | uint16 | `totalFragments` | Total fragments for this update | +| 13 | 4 | uint32 | `payloadBytes` | Bytes of payload following this header | + +### CONFIG Payload + +Sent when the signal set changes or a client connects: +``` +[uint32 numSigs] +numSigs × UDPSSignalDescriptor (136 bytes each, packed) +[uint8 publishMode] +``` + +### DATA Payload (Strict / Decimate modes) +``` +[uint64 HRT timestamp] +per-signal data in CONFIG order (quantised or raw, no inter-signal padding) +``` + +### DATA Payload (Accumulate mode) +``` +[uint64 HRT timestamp] +[uint32 numSamples] +for each signal: if scalar → numSamples elements; else → NumElements once +``` + +### Quantization + +When `QuantizedType` is set on a signal, the server maps `[RangeMin, RangeMax]` to the +full integer range of the quantized type before transmission. The Go client reverses this +using the `Scale` and `Offset` fields from `UDPSSignalDescriptor`. + +--- + +## 3. DebugService Architecture + +### 3.1 Registry Patching (Zero-Code-Change Instrumentation) + +`DebugService::PatchRegistry()` replaces the `ObjectBuilder` for all `MemoryMap*Broker` +types in the MARTe2 `ClassRegistryDatabase`. Any subsequent `ConfigureApplication()` call +will instantiate `DebugBrokerWrapper` objects instead of the originals. No application +source changes are needed. + +Wrapped types: `MemoryMapInputBroker`, `MemoryMapOutputBroker`, +`MemoryMapSynchronisedInputBroker`, `MemoryMapSynchronisedOutputBroker`, +`MemoryMapMultiBufferBroker`, `MemoryMapMultiBufferOutputBroker`, +`MemoryMapAsynchronousInputBroker`, `MemoryMapAsynchronousOutputBroker`, +`MemoryMapInterpolatedInputBroker`, `MemoryMapStatefulOutputBroker`, +`MemoryMapStatefulInputBroker`. + +### 3.2 Signal Registration + +``` +ConfigureApplication() + └─► DebugBrokerWrapper::Init() + └─► DebugBrokerHelper::InitSignals() + ├─► DebugServiceI::RegisterSignal() (canonical + GAM alias) + └─► DebugServiceI::RegisterBroker() +``` + +Each signal is registered twice: +1. **Canonical**: `.` (e.g. `App.Data.DDB.Counter`) +2. **GAM alias**: `.In.` or `.Out.` + +Both map to the same `DebugSignalInfo*`. `AliasMatch()` in `DebugServiceBase.cpp` +performs bidirectional suffix matching so short unqualified names work in commands. + +### 3.3 RT Hot Path + +``` +RealTimeThread::Execute() + └─► DebugBrokerWrapper::Execute() + ├─► Base::Execute() (actual data movement) + └─► DebugBrokerHelper::Process() + ├─► For each active signal: + │ └─► DebugServiceI::ProcessSignal() + │ ├─► if isForcing: memcpy forcedValue → signal memory + │ ├─► if isTracing & decimation fires: push to TraceRingBuffer + │ └─► if breakOp set: evaluate condition → SetPaused(true) + └─► (output brokers only) ConsumeStepIfNeeded() +``` + +### 3.4 TraceRingBuffer + +Single-producer/single-consumer circular byte buffer (4 MB default). +Entry format: `[ID:4][Timestamp:8][Size:4][Data:N]`. + +- `Push()` serialised by `tracePushMutex` (multiple RT threads may write) +- `Pop()` called exclusively by the Streamer thread +- Corrupt entries detected by `size >= bufferSize`; discarded gracefully + +### 3.5 DebugService Threads + +| Thread | Role | +|---|---| +| `Server()` | Accepts one TCP client; reads text commands; writes JSON/text responses | +| `Streamer()` | Drains `TraceRingBuffer`; assembles/sends UDP datagrams; polls monitored signals | + +### 3.6 DebugServiceI Abstraction + +`DebugServiceI.h` defines a pure-virtual singleton so transport implementations +(`DebugService` TCP/UDP, `WebDebugService` HTTP/SSE) share the same broker injection layer. + +```cpp +DebugServiceI::SetInstance(this); // concrete implementation registers itself +DebugServiceI *svc = DebugServiceI::GetInstance(); // broker wrapper retrieves it +``` + +--- + +## 4. UDPStreamer DataSource + +`UDPStreamer` is a standard MARTe2 `DataSourceI` that: + +1. Maintains a list of registered client sessions (UDP source address + port) +2. On each `Synchronise()` call (once per RT cycle): + - Serialises all configured signals into one or more UDPS DATA packets + - Sends to all connected clients +3. Handles `CONNECT` / `DISCONNECT` / `ACK` packets from clients +4. Sends `CONFIG` packets when the signal list changes or a new client connects +5. Applies quantization (`QuantizedType`, `RangeMin`, `RangeMax`) per signal + +### Packed Signals (Accumulate mode) + +When a signal has `NumberOfElements > 1` and `SamplingRate` is configured: +- `TimeMode = FirstSample`: the `TimeSignal` value anchors the burst timestamp +- The Go client interpolates per-sample timestamps as `t0 + e/SamplingRate * dt` +- `UDPStreamer` packs all elements contiguously with no per-element header + +--- + +## 5. Go Client Packages + +### `Common/Client/go/udpsprotocol` + +Pure Go decoder for UDPS packets: +- `Decoder` — reassembles fragments; returns complete CONFIG or DATA payloads +- `SignalDescriptor` — mirrors `UDPSSignalDescriptor` from C++ +- `DataPacket` — decoded signal values with per-sample timestamps + +### `Common/Client/go/wshub` + +WebSocket broadcast hub: +- Multiple browser clients share one UDP → WebSocket relay +- JSON-encodes signal samples and broadcasts to all connected browsers + +### `Client/debugger` + +Go HTTP server providing the debug web UI: +- `main.go` — CLI entry point; starts TCP relay and HTTP server +- `martecontrol.go` — TCP client to `DebugService`; routes commands from browser +- `static/` — Single-page application (HTML/JS/CSS) with Chart.js plots + +--- + +## 6. Key Design Decisions + +| Decision | Rationale | +|---|---| +| Shared `UDPSProtocol.h` in `Common/UDP/` | Eliminates duplication between `UDPStreamer` and any future UDP producer; Go packages decode the same binary layout | +| `DebugServiceI` abstract interface | Allows `DebugService` (TCP/UDP) and `WebDebugService` (HTTP/SSE) to share broker injection without coupling | +| No STL in C++ components | MARTe2 coding convention; `Vec` used instead of `std::vector` | +| `FastPollingMutexSem` on hot path | Lowest-latency synchronisation primitive available in MARTe2; avoids OS scheduler involvement | +| Separate `TCPLogger` component | Decoupled from `DebugService`; can be used with any MARTe2 application independently | +| Go for clients | Minimal dependencies, single binary, easy cross-compilation; no Node/npm toolchain required | diff --git a/Client/debugger/go.mod b/Client/debugger/go.mod new file mode 100644 index 0000000..ff3a651 --- /dev/null +++ b/Client/debugger/go.mod @@ -0,0 +1,12 @@ +module marte2debugger + +go 1.21 + +require marte2/common v0.0.0 + +require ( + github.com/gorilla/websocket v1.5.1 // indirect + golang.org/x/net v0.17.0 // indirect +) + +replace marte2/common => ../../Common/Client/go diff --git a/Client/debugger/go.sum b/Client/debugger/go.sum new file mode 100644 index 0000000..272772f --- /dev/null +++ b/Client/debugger/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= diff --git a/Client/debugger/main.go b/Client/debugger/main.go new file mode 100644 index 0000000..3e732e3 --- /dev/null +++ b/Client/debugger/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "embed" + "errors" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + + "marte2/common/wshub" +) + +var buildVersion = "dev" + +//go:embed static +var staticFiles embed.FS + +func main() { + addr := flag.String("addr", ":7777", "HTTP listen address") + sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list") + flag.Parse() + + hub := wshub.NewHub() + sm := wshub.NewSourceManager(hub, *sourcesFile) + hub.SetSourceManager(sm) + + ctrl := NewMarteController(hub) + + go hub.Run() + + // Load persisted sources + if *sourcesFile != "" { + if err := sm.Load(*sourcesFile); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("sources-file load: %v", err) + } + } + + // Register the debug source (always present; state managed by MarteController) + hub.AddSource("debug", "MARTe2 Debug", "") + + // Forward browser debug commands to MarteController + go func() { + for msg := range hub.DebugCh { + ctrl.HandleBrowserCommand(msg) + } + }() + + sub, err := fs.Sub(staticFiles, "static") + if err != nil { + log.Fatalf("static sub-fs: %v", err) + } + http.Handle("/", http.FileServer(http.FS(sub))) + http.HandleFunc("/ws", hub.HandleWebSocket) + http.HandleFunc("/api/zoom", hub.HandleZoom) + http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, buildVersion) + }) + + log.Printf("MARTe2 Integrated Client listening on %s (build=%s)", *addr, buildVersion) + if err := http.ListenAndServe(*addr, nil); err != nil { + log.Fatalf("http: %v", err) + } +} diff --git a/Client/debugger/martecontrol.go b/Client/debugger/martecontrol.go new file mode 100644 index 0000000..45b2bef --- /dev/null +++ b/Client/debugger/martecontrol.go @@ -0,0 +1,895 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log" + "net" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "marte2/common/udpsprotocol" + "marte2/common/wshub" +) + +// --------------------------------------------------------------------------- +// Signal metadata (populated by DISCOVER) +// --------------------------------------------------------------------------- + +type SignalMeta struct { + Name string `json:"name"` + ID uint32 `json:"id"` + Type string `json:"type"` + Dimensions uint8 `json:"dimensions"` + Elements uint32 `json:"elements"` + Names []string // canonical + alias names mapping to this ID +} + +// --------------------------------------------------------------------------- +// Outbound broadcast helpers +// --------------------------------------------------------------------------- + +func broadcastHub(hub *wshub.Hub, v any) { + b, err := json.Marshal(v) + if err != nil { + return + } + hub.Broadcast(b) +} + +// --------------------------------------------------------------------------- +// MarteController +// --------------------------------------------------------------------------- + +type MarteController struct { + hub *wshub.Hub + + mu sync.Mutex + tcpConn net.Conn + writer *bufio.Writer + + cmdMu sync.Mutex // serialise TCP writes + + sigMu sync.RWMutex + signals map[uint32]*SignalMeta // id -> meta + tracedMu sync.RWMutex + tracedNames map[string]bool // user-visible names currently being traced + + // Persistent forced-signal state: replayed to new browser clients so the + // forced-signals panel stays correct across page reloads. + forcedMu sync.RWMutex + forcedState map[string]string // signal key (may include [i]) → forced value + + baseTsSet bool + basesMu sync.Mutex + + connected int32 // atomic bool + + lastWriteMs int64 // atomic; updated on every TCP write, used by keepalive + + stopCh chan struct{} + + // accumulates signals across DISCOVER_PART chunks; merged on final DISCOVER + discoverAcc []discoverSignalJSON + + // cached last-known ports (updated by SERVICE_INFO) + host string + cmdPort int + udpPort int + logPort int +} + +// NewMarteController creates a MarteController bound to the given hub. +func NewMarteController(hub *wshub.Hub) *MarteController { + mc := &MarteController{ + hub: hub, + signals: make(map[uint32]*SignalMeta), + tracedNames: make(map[string]bool), + forcedState: make(map[string]string), + stopCh: make(chan struct{}), + } + // Register the new-client hook so connection + forced/traced state is + // replayed to any browser that connects (or reconnects) while the server + // already holds a live MARTe2 TCP session. + hub.SetOnClientConnect(mc.replayStateToClient) + return mc +} + +func (m *MarteController) IsConnected() bool { + return atomic.LoadInt32(&m.connected) == 1 +} + +// replayStateToClient is called by the hub whenever a new WebSocket client +// connects. It sends the current MARTe2 connection status and any persistent +// forced/traced signal state so the browser UI is always consistent. +func (m *MarteController) replayStateToClient(send func([]byte)) { + if !m.IsConnected() { + return + } + // Re-send "connected" so the browser updates its UI state. + if b, err := json.Marshal(map[string]any{"type": "connected"}); err == nil { + send(b) + } + + // Replay forced signals. + m.forcedMu.RLock() + forced := make(map[string]string, len(m.forcedState)) + for k, v := range m.forcedState { + forced[k] = v + } + m.forcedMu.RUnlock() + if len(forced) > 0 { + if b, err := json.Marshal(map[string]any{"type": "forced_state", "signals": forced}); err == nil { + send(b) + } + } + + // Replay traced signal names. + m.tracedMu.RLock() + traced := make([]string, 0, len(m.tracedNames)) + for n := range m.tracedNames { + traced = append(traced, n) + } + m.tracedMu.RUnlock() + if len(traced) > 0 { + if b, err := json.Marshal(map[string]any{"type": "traced_state", "signals": traced}); err == nil { + send(b) + } + } +} + +// --------------------------------------------------------------------------- +// Connect / Disconnect +// --------------------------------------------------------------------------- + +func (m *MarteController) Connect(host string, cmdPort, udpPort, logPort int) { + m.Disconnect() + + m.mu.Lock() + m.host = host + m.cmdPort = cmdPort + m.udpPort = udpPort + m.logPort = logPort + m.stopCh = make(chan struct{}) + m.mu.Unlock() + + // Update source state so the browser shows "connecting". + m.hub.SetSourceState("debug", "connecting") + + broadcastHub(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "INFO", "message": fmt.Sprintf("Connecting to %s cmd=%d udp=%d log=%d", host, cmdPort, udpPort, logPort), + }) + + go m.runTCP(host, cmdPort) + go m.runDebugUDP(host, udpPort) + go m.runLog(host, logPort) +} + +func (m *MarteController) Disconnect() { + m.mu.Lock() + select { + case <-m.stopCh: + // already closed + default: + close(m.stopCh) + } + if m.tcpConn != nil { + m.tcpConn.Close() + m.tcpConn = nil + } + m.mu.Unlock() + + atomic.StoreInt32(&m.connected, 0) + m.basesMu.Lock() + m.baseTsSet = false + m.basesMu.Unlock() + m.discoverAcc = nil + m.hub.SetSourceState("debug", "disconnected") +} + +func (m *MarteController) stopped() bool { + select { + case <-m.stopCh: + return true + default: + return false + } +} + +// --------------------------------------------------------------------------- +// HandleBrowserCommand — dispatch JSON commands from browser +// --------------------------------------------------------------------------- + +func (m *MarteController) HandleBrowserCommand(msg []byte) { + var env map[string]interface{} + if err := json.Unmarshal(msg, &env); err != nil { + return + } + t, _ := env["type"].(string) + switch t { + case "connect": + data, _ := env["data"].(map[string]interface{}) + if data == nil { + return + } + host, _ := data["host"].(string) + portF, _ := data["port"].(float64) + udpPortF, _ := data["udp_port"].(float64) + logPortF, _ := data["log_port"].(float64) + if host == "" { + host = "127.0.0.1" + } + port := int(portF) + if port == 0 { + port = 8080 + } + udpPort := int(udpPortF) + if udpPort == 0 { + udpPort = port + 1 + } + logPort := int(logPortF) + if logPort == 0 { + logPort = port + 2 + } + m.Connect(host, port, udpPort, logPort) + + case "disconnect": + m.Disconnect() + + case "cmd": + data, _ := env["data"].(map[string]interface{}) + if data == nil { + return + } + cmd, _ := data["cmd"].(string) + if cmd != "" { + m.trackForcedCmd(cmd) + m.SendCommand(cmd) + } + } +} + +// --------------------------------------------------------------------------- +// TCP command channel +// --------------------------------------------------------------------------- + +func (m *MarteController) runTCP(host string, port int) { + addr := fmt.Sprintf("%s:%d", host, port) + for !m.stopped() { + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + broadcastHub(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "WARNING", "message": fmt.Sprintf("TCP %s: %v — retrying…", addr, err), + }) + time.Sleep(2 * time.Second) + continue + } + conn.(*net.TCPConn).SetNoDelay(true) + + m.mu.Lock() + m.tcpConn = conn + m.writer = bufio.NewWriter(conn) + m.mu.Unlock() + + atomic.StoreInt32(&m.connected, 1) + broadcastHub(m.hub, map[string]any{"type": "connected"}) + + // Send SERVICE_INFO to auto-discover ports + m.writeCmd("SERVICE_INFO") + + go m.runKeepalive() + + m.readLoop(conn) + + atomic.StoreInt32(&m.connected, 0) + broadcastHub(m.hub, map[string]any{"type": "disconnected"}) + + m.mu.Lock() + m.tcpConn = nil + m.writer = nil + m.mu.Unlock() + + if !m.stopped() { + time.Sleep(2 * time.Second) + } + } +} + +func (m *MarteController) writeCmd(cmd string) { + m.cmdMu.Lock() + defer m.cmdMu.Unlock() + m.mu.Lock() + w := m.writer + m.mu.Unlock() + if w == nil { + return + } + // Suppress high-frequency polling commands from both terminal and browser logs. + silent := cmd == "STEP_STATUS" || cmd == "INFO" + if !silent { + log.Printf("[→MARTe] %s", cmd) + broadcastHub(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "CMD", "message": fmt.Sprintf("→ %s", cmd), + }) + } + w.WriteString(cmd + "\n") + w.Flush() + atomic.StoreInt64(&m.lastWriteMs, time.Now().UnixMilli()) +} + +// runKeepalive sends INFO every 20 s when idle. +func (m *MarteController) runKeepalive() { + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + if !m.IsConnected() { + continue + } + idleMs := time.Now().UnixMilli() - atomic.LoadInt64(&m.lastWriteMs) + if idleMs >= 20_000 { + m.writeCmd("INFO") + } + } + } +} + +// trackForcedCmd intercepts FORCE/UNFORCE commands to maintain the persistent +// forced-signal state that is replayed to new browser clients. +func (m *MarteController) trackForcedCmd(cmd string) { + parts := strings.Fields(cmd) + if len(parts) < 2 { + return + } + switch strings.ToUpper(parts[0]) { + case "FORCE": + if len(parts) >= 3 { + key := parts[1] + val := strings.Join(parts[2:], " ") + m.forcedMu.Lock() + m.forcedState[key] = val + m.forcedMu.Unlock() + } + case "UNFORCE": + key := parts[1] + m.forcedMu.Lock() + // Remove the exact key and any element keys that share the same base name + // (e.g., UNFORCE Foo removes Foo, Foo[0], Foo[1], …). + delete(m.forcedState, key) + prefix := key + "[" + for k := range m.forcedState { + if strings.HasPrefix(k, prefix) { + delete(m.forcedState, k) + } + } + m.forcedMu.Unlock() + } +} + +func (m *MarteController) SendCommand(cmd string) { + // Track TRACE enable/disable so translateSignalNames can pick the right alias. + if strings.HasPrefix(cmd, "TRACE ") { + parts := strings.Fields(cmd) + if len(parts) == 3 { + name := parts[1] + enable := parts[2] == "1" + m.tracedMu.Lock() + if enable { + m.tracedNames[name] = true + } else { + delete(m.tracedNames, name) + } + m.tracedMu.Unlock() + } + } + m.writeCmd(cmd) +} + +func (m *MarteController) readLoop(conn net.Conn) { + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 8*1024*1024), 8*1024*1024) + + var jsonAcc strings.Builder + inJSON := false + + for scanner.Scan() { + if m.stopped() { + return + } + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + // Detect start of JSON block + if !inJSON && strings.HasPrefix(trimmed, "{") { + inJSON = true + jsonAcc.Reset() + } + + if inJSON { + jsonAcc.WriteString(trimmed) + tag, done := detectJSONDone(trimmed) + if done { + inJSON = false + raw := jsonAcc.String() + // Strip trailing sentinel + idx := strings.Index(raw, "OK "+tag) + if idx >= 0 { + raw = strings.TrimSpace(raw[:idx]) + } + m.handleJSONResponse(tag, raw) + jsonAcc.Reset() + } + } else { + m.handleTextLine(trimmed) + } + } +} + +func detectJSONDone(line string) (string, bool) { + tags := []string{ + "DISCOVER_PART", "DISCOVER", "TREE", "INFO", "CONFIG", "STEP_STATUS", + "VALUE", "MSG", "TRACE", "FORCE", "UNFORCE", "BREAK", + "PAUSE", "RESUME", "STEP", "MONITOR", "UNMONITOR", "LS", + } + for _, t := range tags { + if line == "OK "+t { + return t, true + } + } + return "", false +} + +func (m *MarteController) handleJSONResponse(tag, data string) { + silent := tag == "STEP_STATUS" || tag == "INFO" + if !silent { + log.Printf("[←MARTe] %s %d bytes", tag, len(data)) + broadcastHub(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "RESP", "message": fmt.Sprintf("← %s (%d B)", tag, len(data)), + }) + } + switch tag { + case "DISCOVER_PART": + var resp discoverResp + if err := json.Unmarshal([]byte(data), &resp); err != nil { + log.Printf("[DISCOVER_PART] parse error: %v", err) + return + } + m.discoverAcc = append(m.discoverAcc, resp.Signals...) + log.Printf("[DISCOVER_PART] accumulated %d signals (total so far: %d)", + len(resp.Signals), len(m.discoverAcc)) + return + + case "DISCOVER": + var resp discoverResp + if err := json.Unmarshal([]byte(data), &resp); err != nil { + log.Printf("[DISCOVER] parse error: %v", err) + return + } + all := append(m.discoverAcc, resp.Signals...) + m.discoverAcc = nil + m.parseDiscoverSignals(all) + // Synthesize SignalInfo for the hub so the plot system gets a config + m.synthesizeHubConfig(all) + // Re-marshal the merged list so the browser gets a single consistent blob. + merged, _ := json.Marshal(discoverResp{Signals: all}) + broadcastHub(m.hub, map[string]any{ + "type": "response", "tag": "DISCOVER", "data": string(merged), + }) + return + + case "TREE": + broadcastHub(m.hub, map[string]any{ + "type": "tree_node", + "data": data, + }) + return + } + broadcastHub(m.hub, map[string]any{ + "type": "response", + "tag": tag, + "data": data, + }) +} + +func (m *MarteController) handleTextLine(line string) { + if strings.HasPrefix(line, "OK SERVICE_INFO") { + parts := strings.Fields(line) + newUDP, newLog := 0, 0 + for _, p := range parts { + if strings.HasPrefix(p, "UDP_STREAM:") { + fmt.Sscanf(p[11:], "%d", &newUDP) + } + if strings.HasPrefix(p, "TCP_LOG:") { + fmt.Sscanf(p[8:], "%d", &newLog) + } + } + broadcastHub(m.hub, map[string]any{ + "type": "response", + "tag": "SERVICE_INFO", + "data": line[len("OK SERVICE_INFO "):], + }) + if newUDP > 0 || newLog > 0 { + broadcastHub(m.hub, map[string]any{ + "type": "service_config", + "udp_port": newUDP, + "log_port": newLog, + }) + m.mu.Lock() + host := m.host + oldUDP := m.udpPort + oldLog := m.logPort + if newUDP > 0 { + m.udpPort = newUDP + } + if newLog > 0 { + m.logPort = newLog + } + m.mu.Unlock() + if newUDP > 0 && newUDP != oldUDP { + go m.runDebugUDP(host, newUDP) + } + if newLog > 0 && newLog != oldLog { + go m.runLog(host, newLog) + } + } + } + broadcastHub(m.hub, map[string]any{ + "type": "text_line", + "data": line, + }) +} + +// --------------------------------------------------------------------------- +// DISCOVER parsing +// --------------------------------------------------------------------------- + +type discoverSignalJSON struct { + Name string `json:"name"` + ID uint32 `json:"id"` + Type string `json:"type"` + Dimensions uint8 `json:"dimensions"` + Elements uint32 `json:"elements"` +} + +type discoverResp struct { + Signals []discoverSignalJSON `json:"Signals"` +} + +func (m *MarteController) parseDiscoverSignals(sigs []discoverSignalJSON) { + m.sigMu.Lock() + defer m.sigMu.Unlock() + m.signals = make(map[uint32]*SignalMeta, len(sigs)) + for _, s := range sigs { + el := s.Elements + if el == 0 { + el = 1 + } + if existing, ok := m.signals[s.ID]; ok { + existing.Names = append(existing.Names, s.Name) + continue + } + meta := &SignalMeta{ + Name: s.Name, + ID: s.ID, + Type: s.Type, + Dimensions: s.Dimensions, + Elements: el, + Names: []string{s.Name}, + } + m.signals[s.ID] = meta + } + log.Printf("[DISCOVER] registered %d unique signals from %d entries", len(m.signals), len(sigs)) +} + +// translateSignalNames maps UDPS signal names (DS canonical, e.g. "DDB1.Sine1") +// to the preferred GAM-path alias (e.g. "App.Functions.SineGAM1.Out.Sine1"). +// +// DebugService always sets signals[i]->name to the first registered name, which +// is the DataSource canonical path. The user-facing name in the tree and the +// tracedSet is the GAM alias (contains ".Out." or ".In."). Without this +// translation the buffer key created from UDPS CONFIG ("debug:DDB1.Sine1") +// never matches the buffer key the tracedTab looks up ("debug:App…Out.Sine1"). +func (m *MarteController) translateSignalNames(sigs []udpsprotocol.SignalInfo) []udpsprotocol.SignalInfo { + m.sigMu.RLock() + defer m.sigMu.RUnlock() + + if len(m.signals) == 0 { + return sigs // no DISCOVER data yet — return as-is + } + + // Build a reverse map: every known alias name → SignalMeta + nameToMeta := make(map[string]*SignalMeta, len(m.signals)*2) + for _, meta := range m.signals { + for _, n := range meta.Names { + nameToMeta[n] = meta + } + } + + // Snapshot the user's currently-traced names for matching. + m.tracedMu.RLock() + traced := make(map[string]bool, len(m.tracedNames)) + for k, v := range m.tracedNames { + traced[k] = v + } + m.tracedMu.RUnlock() + + result := make([]udpsprotocol.SignalInfo, len(sigs)) + for i, sig := range sigs { + result[i] = sig + meta, ok := nameToMeta[sig.Name] + if !ok { + continue + } + best := sig.Name + // Priority 1: find a traced name that alias-matches this signal (mirrors + // C++ AliasMatch: exact or suffix in either direction, dot-boundary). + outer: + for _, n := range meta.Names { + for tracedName := range traced { + if aliasMatch(n, tracedName) { + best = tracedName // use the name the user sees in the tree + break outer + } + } + } + // Priority 2 (fallback): longest alias with ".Out." or ".In." (GAM path). + if best == sig.Name { + for _, n := range meta.Names { + if (strings.Contains(n, ".Out.") || strings.Contains(n, ".In.")) && len(n) > len(best) { + best = n + } + } + } + if best != sig.Name { + log.Printf("[debug-udp] rename %q → %q", sig.Name, best) + result[i].Name = best + } + } + return result +} + +// aliasMatch mirrors C++ AliasMatch: returns true if a and b are equal or one +// is a dot-boundary suffix of the other. +func aliasMatch(a, b string) bool { + if a == b { + return true + } + return suffixMatchDot(a, b) || suffixMatchDot(b, a) +} + +func suffixMatchDot(str, suffix string) bool { + if len(suffix) > len(str) { + return false + } + tail := str[len(str)-len(suffix):] + if tail != suffix { + return false + } + return len(str) == len(suffix) || str[len(str)-len(suffix)-1] == '.' +} + +// typeCodeFromString maps a MARTe2 type string to a UDPS type code. +func typeCodeFromString(t string) uint8 { + switch strings.ToLower(t) { + case "uint8": + return 0 + case "int8": + return 1 + case "uint16": + return 2 + case "int16": + return 3 + case "uint32": + return 4 + case "int32": + return 5 + case "uint64": + return 6 + case "int64": + return 7 + case "float32": + return 8 + case "float64": + return 9 + default: + return 4 // default to uint32 + } +} + +// synthesizeHubConfig converts DISCOVER signals to udpsprotocol.SignalInfo and +// sends them to the hub so the signal list panel is populated before UDP data arrives. +func (m *MarteController) synthesizeHubConfig(sigs []discoverSignalJSON) { + seen := make(map[uint32]bool) + var sigInfos []udpsprotocol.SignalInfo + for _, s := range sigs { + if seen[s.ID] { + continue + } + seen[s.ID] = true + el := s.Elements + if el == 0 { + el = 1 + } + numRows := el + numCols := uint32(1) + if s.Dimensions >= 2 { + numCols = el + numRows = 1 + } + si := udpsprotocol.SignalInfo{ + Name: s.Name, + TypeCode: typeCodeFromString(s.Type), + QuantType: 0, + NumDimensions: s.Dimensions, + NumRows: numRows, + NumCols: numCols, + RangeMin: 0, + RangeMax: 0, + TimeMode: udpsprotocol.TimeModePacket, + SamplingRate: 0, + TimeSignalIdx: udpsprotocol.NoTimeSignal, + Unit: "", + } + sigInfos = append(sigInfos, si) + } + m.hub.UpdateConfigForSource("debug", sigInfos) +} + +// --------------------------------------------------------------------------- +// Debug UDP receiver — receives UDPS packets from DebugService +// --------------------------------------------------------------------------- + +func (m *MarteController) runDebugUDP(host string, port int) { + addr := fmt.Sprintf("0.0.0.0:%d", port) + + // Use SO_REUSEPORT so we can bind the same port that DebugService already + // holds open. Without this the second bind fails with EADDRINUSE and we + // receive nothing. + lc := net.ListenConfig{ + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + const SO_REUSEPORT = 0xf // Linux SO_REUSEPORT = 15 + if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, SO_REUSEPORT, 1); err != nil { + log.Printf("[debug-udp] SO_REUSEPORT: %v", err) + } + }) + }, + } + pc, err := lc.ListenPacket(context.Background(), "udp4", addr) + if err != nil { + msg := fmt.Sprintf("UDP bind on %s failed: %v — rebuild DebugService C++ and restart", addr, err) + log.Printf("[debug-udp] %s", msg) + broadcastHub(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "ERROR", "message": msg, + }) + return + } + conn := pc.(*net.UDPConn) + defer conn.Close() + conn.SetReadBuffer(10 * 1024 * 1024) + + log.Printf("[debug-udp] listening on %s for UDPS packets", addr) + broadcastHub(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "INFO", "message": fmt.Sprintf("UDP listener bound on %s", addr), + }) + + reassembler := udpsprotocol.NewReassembler(2 * time.Second) + buf := make([]byte, 65535) + var currentSigs []udpsprotocol.SignalInfo + var currentPublishMode uint8 + var pktCount int64 + + for !m.stopped() { + conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, _, err := conn.ReadFromUDP(buf) + arrivalTime := time.Now() + if err != nil { + continue + } + pktCount++ + + if n < udpsprotocol.HeaderSize { + continue + } + + hdr, err := udpsprotocol.ParseHeader(buf[:n]) + if err != nil { + continue + } + + payload := make([]byte, n-udpsprotocol.HeaderSize) + copy(payload, buf[udpsprotocol.HeaderSize:n]) + + complete, ok := reassembler.AddFragment(hdr, payload) + if !ok { + continue + } + + switch hdr.Type { + case udpsprotocol.PktConfig: + sigs, pm, err := udpsprotocol.ParseConfig(complete) + if err != nil { + log.Printf("[debug-udp] parse config: %v", err) + continue + } + sigs = m.translateSignalNames(sigs) + currentSigs = sigs + currentPublishMode = pm + m.hub.UpdateConfigForSource("debug", sigs) + m.hub.SetSourceState("debug", "connected") + + case udpsprotocol.PktData: + if len(currentSigs) == 0 { + continue + } + samples, err := udpsprotocol.ParseData(complete, currentSigs, currentPublishMode, arrivalTime) + if err != nil { + log.Printf("[debug-udp] parse data: %v", err) + continue + } + for _, s := range samples { + m.hub.PushDataForSource("debug", s) + } + } + } + log.Printf("[debug-udp] stopped") +} + +// --------------------------------------------------------------------------- +// TCP log channel +// --------------------------------------------------------------------------- + +func (m *MarteController) runLog(host string, port int) { + addr := fmt.Sprintf("%s:%d", host, port) + for !m.stopped() { + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + if m.stopped() { + conn.Close() + return + } + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "LOG ") { + rest := line[4:] + idx := strings.Index(rest, " ") + if idx < 0 { + continue + } + level := rest[:idx] + msg := rest[idx+1:] + broadcastHub(m.hub, map[string]any{ + "type": "log", + "time": time.Now().Format("15:04:05.000"), + "level": level, + "message": msg, + }) + } + } + conn.Close() + if !m.stopped() { + time.Sleep(2 * time.Second) + } + } +} diff --git a/Client/debugger/static/app.js b/Client/debugger/static/app.js new file mode 100644 index 0000000..65461f3 --- /dev/null +++ b/Client/debugger/static/app.js @@ -0,0 +1,4629 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════ + Constants + ════════════════════════════════════════════════════════════════ */ +// Largest window option in the UI (seconds). Buffers are pre-sized to hold this much history. +const MAX_WINDOW_SEC = 60; +// Hard ceiling per buffer (~9.6 MB per signal at Float64 t+v). +const MAX_CAP = 600_000; +// Default capacity for signals whose rate is unknown: covers 60 s at ~1.5 kHz. +const DEFAULT_CAP = 100_000; +// Temporal signals receive up to 50 pts/tick × 200 Hz = 10 000 pts/s. +// TEMPORAL_CAP covers the full 60-second window for rates up to ~8 kHz. +const TEMPORAL_CAP = Math.min(MAX_CAP, 500_000); +const LTTB_MIN = 200; // never decimate below this many points + +// Compute a buffer capacity that holds MAX_WINDOW_SEC * 1.5 of data at rateHz. +function bufferCapForRate(rateHz) { + if (!rateHz || rateHz <= 0) return DEFAULT_CAP; + return Math.min(MAX_CAP, Math.ceil(rateHz * MAX_WINDOW_SEC * 1.5)); +} + +// Return a new, larger circular buffer that preserves all existing samples. +function growBuffer(buf, newCap) { + if (newCap <= buf.cap) return buf; + const nb = makeBuffer(newCap); + const start = buf.size === buf.cap ? buf.head : 0; + for (let i = 0; i < buf.size; i++) { + const idx = (start + i) % buf.cap; + pushBuffer(nb, buf.t[idx], buf.v[idx]); + } + return nb; +} + +const TRACE_COLORS = [ + '#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7', + '#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7', +]; + +/* ════════════════════════════════════════════════════════════════ + Globals + ════════════════════════════════════════════════════════════════ */ +// sourcesMap: id → {id, label, addr, state, signals:[]} +const sourcesMap = {}; +let buffers = {}; +let plots = []; +let nextPlotId = 1; +let windowSec = 5; +let globalPause = false; +let lastDataAt = 0; + +/* ── Debug-specific globals ─────────────────────────────────────── */ +let marteConnected = false; +let signalsById = {}; // id → {id, name, type, elements, dimensions, names:[]} +let signalsByName = {}; // name → meta (multiple entries per signal) +let tracedSet = new Set(); +let forcedSignals = {}; // name → value +const forcedGroups = new Map(); // baseName → {isAll, indices, elements, value, expanded} +let breakpoints = {}; // name → {op, threshold} +const tracedGroups = new Map(); // baseName → {indices, elements, expanded} +let threads = []; +let allObjects = []; +let logs = []; +const MAX_LOGS = 2000; +let logDirty = false; +let stepStatus = null; // {paused, gam, remaining, thread} +let stepPollTimer = null; +let unforceTarget = ''; +const _cmdSent = new Set(); +let _treeReady = false; +let _arrAction = 'trace', _arrPath = '', _arrElem = 0, _arrSel = 'all'; + +const traceColorMap = {}; +let colorIdx = 0; +function getTraceColor(key) { + if (!traceColorMap[key]) traceColorMap[key] = TRACE_COLORS[colorIdx++ % TRACE_COLORS.length]; + return traceColorMap[key]; +} + +// Per-signal style overrides (color, width, dash, marker, markerSize). +const sigStyle = {}; +function getSigStyle(key) { + if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 }; + return sigStyle[key]; +} + +// Per-signal vertical scale state: key → {mode, divValue, offset, _resolvedDiv, _resolvedOffset} +const sigVScale = {}; +// Active signal per plot: plotId → key +const plotActiveSignal = {}; + +function setSigStyle(key, updates) { + const s = getSigStyle(key); + Object.assign(s, updates); + if (updates.color) { + traceColorMap[key] = updates.color; + // Update badge dots for this key across all plots + document.querySelectorAll(`.sig-badge[data-key="${CSS.escape(key)}"] .trace-dot`).forEach(dot => { + dot.style.background = updates.color; + }); + } + // Recreate uPlot for all plots containing this key + plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } }); +} + +/* ─── VScale helpers ─────────────────────────────────────────────────────── */ +// vsKey: compound key "plotId:signalKey" so same signal in different plots is independent. +function getVScale(plotId, key) { + const vsKey = plotId + ':' + key; + if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null, digitalInMixed: false }; + return sigVScale[vsKey]; +} + +function findSignalMeta(key) { + const colon = key.indexOf(':'); + if (colon < 0) return null; + const src = sourcesMap[key.slice(0, colon)]; + if (!src) return null; + return src.signals.find(s => s.name === key.slice(colon + 1)) || null; +} + +// Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array. +// y_norm = (y_raw - offset) / divValue + screenPos +// divValue: units per division offset: raw value at screen center screenPos: divisions from center +// Also caches the resolved values in vs._resolvedDiv/_resolvedOffset for Y-axis label use. +function resolveVScale(plotId, key, rawY) { + const vs = getVScale(plotId, key); + const screenPos = vs.screenPos || 0; + if (vs.mode === 'range') { + const meta = findSignalMeta(key); + if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) { + const divValue = (meta.rangeMax - meta.rangeMin) / 8; + const offset = (meta.rangeMin + meta.rangeMax) / 2; + vs._resolvedDiv = divValue; vs._resolvedOffset = offset; + return { divValue, offset, screenPos }; + } + // Fall through to auto if no range + } + if (vs.mode === 'manual') { + const divValue = Math.max(vs.divValue || 1, 1e-30); + const offset = vs.offset != null ? vs.offset : 0; + vs._resolvedDiv = divValue; vs._resolvedOffset = offset; + return { divValue, offset, screenPos }; + } + // Auto: fit data in central 6 of 8 divisions, centered at screenPos + let min = Infinity, max = -Infinity; + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; + if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } + } + if (!isFinite(min)) { min = -1; max = 1; } + if (min === max) { min -= 1; max += 1; } + const divValue = Math.max((max - min) / 6, 1e-30); + const offset = (max + min) / 2; + vs._resolvedDiv = divValue; vs._resolvedOffset = offset; + return { divValue, offset, screenPos }; +} + +// Mixed mode: each signal occupies a fixed band; within that band it is either +// quantized (digital) or auto-scaled (analog) based on vs.digitalInMixed. +function applyMixedNorm(p, yArrays) { + const n = p.traces.length; + if (n === 0) return yArrays; + const bandH = 8 / n; + return yArrays.map((rawY, ki) => { + const key = p.traces[ki]; + const vs = getVScale(p.id, key); + const centerY = 4 - (ki + 0.5) * bandH; + const hi = centerY + bandH * 0.35; + const lo = centerY - bandH * 0.35; + let min = Infinity, max = -Infinity; + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } + } + const out = new Float64Array(rawY.length); + if (vs.digitalInMixed) { + const threshold = isFinite(min) ? (min + max) / 2 : 0.5; + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; + out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo); + } + } else { + if (!isFinite(min)) { min = 0; max = 1; } + if (min === max) { min -= 1; max += 1; } + const range = max - min, bandRange = hi - lo; + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; + out[i] = (v == null || !isFinite(v)) ? NaN : lo + (v - min) / range * bandRange; + } + } + return out; + }); +} + +// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces). +// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos. +function applyVScaleNorm(p, yArrays) { + if (p.mode === 'digital') return applyDigitalNorm(p, yArrays); + if (p.mode === 'mixed') return applyMixedNorm(p, yArrays); + return yArrays.map((rawY, ki) => { + const key = p.traces[ki]; + const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY); + const out = new Float64Array(rawY.length); + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; + out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos; + } + return out; + }); +} + +// Digital mode: quantize each signal to lo/hi within its own horizontal band. +// Signals are arranged top-to-bottom matching badge order (index 0 = top). +function applyDigitalNorm(p, yArrays) { + const n = p.traces.length; + if (n === 0) return yArrays; + const bandH = 8 / n; // total Y span is 8 divisions (-4 to +4) + return yArrays.map((rawY, ki) => { + // Top-down: signal 0 is at top (highest Y value) + const centerY = 4 - (ki + 0.5) * bandH; + const hi = centerY + bandH * 0.35; + const lo = centerY - bandH * 0.35; + // Threshold: midpoint of min/max + let min = Infinity, max = -Infinity; + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } + } + const threshold = isFinite(min) ? (min + max) / 2 : 0.5; + const out = new Float64Array(rawY.length); + for (let i = 0; i < rawY.length; i++) { + const v = rawY[i]; + out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo); + } + return out; + }); +} + +// Set the active (Y-axis-labelled) signal for a plot and update badge highlights. +function setActiveSig(plotId, key) { + if (key === null || key === undefined) { + delete plotActiveSignal[plotId]; + } else { + plotActiveSignal[plotId] = key; + } + const c = document.getElementById('badges-' + plotId); + if (c) c.querySelectorAll('.sig-badge').forEach(b => + b.classList.toggle('sig-badge-active', key != null && b.dataset.key === key)); + const p = plots.find(q => q.id === plotId); + if (p && p.uplot) p.uplot.redraw(false); + updatePlotCursorReadouts(); +} + +// Mark plots containing key dirty and refresh badge vscale text. +function refreshPlotForKey(key) { + plots.forEach(p => { + if (p.traces.includes(key)) { + p.needsRedraw = true; + _updateBadgeVScaleInfo(p.id, key); + } + }); +} + +// Format a numeric value concisely for badge/axis display. +function _fmtVal(v) { + if (v == null || !isFinite(v)) return '?'; + const abs = Math.abs(v); + if (abs === 0) return '0'; + if (abs >= 1e4 || abs < 1e-3) return v.toExponential(1); + return parseFloat(v.toPrecision(3)).toString(); +} + +// Refresh the vscale info text inside a badge. +function _updateBadgeVScaleInfo(plotId, key) { + const c = document.getElementById('badges-' + plotId); if (!c) return; + const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (!b) return; + const infoEl = b.querySelector('.vscale-info'); if (!infoEl) return; + const vs = sigVScale[plotId + ':' + key]; + if (!vs) { infoEl.textContent = ''; return; } + const divValue = vs._resolvedDiv || vs.divValue || 1; + const sp = vs.screenPos || 0; + let txt = _fmtVal(divValue) + '/div'; + if (sp !== 0) txt += ' ' + (sp >= 0 ? '+' : '') + sp.toFixed(1) + 'div'; + infoEl.textContent = txt; +} + +// Sync: shared uPlot cursor crosshair across all live plots +const LIVE_SYNC = uPlot.sync('live'); +const TRIG_SYNC = uPlot.sync('trig'); + +// Zoom guard: prevents echo on cross-plot sync calls inside onZoom. +// zoomGuard prevents the setScale hook from calling onZoom when we programmatically +// set the scale (rolling window, zoom-back, fit, resize, pan, cross-plot sync). +// All programmatic setScale calls wrap with zoomGuard=true/false so that the hook +// only fires for genuine user drag-zoom or scroll-wheel gestures. +let zoomGuard = false; + +// Zoom history for Back button (global since plots are zoom-synced) +const zoomHistory = []; + +// zoomData: hi-res data fetched from /api/zoom, keyed by plot id. +// Each entry: { signals: { key: {t:Float64Array, v:Float64Array} }, t0, t1 } +const zoomData = {}; +let _zoomFetchTimer = null; + +// Cursors A/B — stored in x-axis units of the current mode: +// live mode → Unix seconds +// trig mode → relative seconds from trigger +const cursors = { mode: 'off', tA: null, tB: null }; +let cursorsDirty = false; // if true, redraw all plots to update cursor lines + +// Layout — [label, cssClass, cols, rows] +const LAYOUTS = [ + ['1×1', 'l1x1', 1, 1], ['1×2', 'l1x2', 1, 2], ['2×1', 'l2x1', 2, 1], ['1×3', 'l1x3', 1, 3], + ['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1], +]; +let currentLayout = 'l1x1'; +let colFrs = [1]; // fractional column sizes (sum = cols) +let rowFrs = [1]; // fractional row sizes (sum = rows) +let _gridCols = 1, _gridRows = 1; + +/* ════════════════════════════════════════════════════════════════ + Trigger state + ════════════════════════════════════════════════════════════════ */ +const trig = { + enabled: false, signal: '', edge: 'rising', threshold: 0, windowSec: 1, + prePercent: 20, mode: 'normal', stopped: false, + armed: false, prevVal: null, collecting: false, trigTime: null, snapshot: null, +}; +function trigPreSec() { return trig.windowSec * trig.prePercent / 100; } +function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; } + +/* ════════════════════════════════════════════════════════════════ + WebSocket + ════════════════════════════════════════════════════════════════ */ +let ws = null, wsBackoff = 1000; +function connectWS() { + ws = new WebSocket('ws://' + location.host + '/ws'); + ws.binaryType = 'arraybuffer'; + ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected – waiting for data'); }; + ws.onclose = () => { + setStatus('red', 'Disconnected (reconnecting…)'); + setTimeout(connectWS, wsBackoff); + wsBackoff = Math.min(wsBackoff * 2, 30000); + }; + ws.onerror = () => { }; + ws.onmessage = evt => { + if (evt.data instanceof ArrayBuffer) { onBinaryData(evt.data); return; } + let msg; try { msg = JSON.parse(evt.data); } catch { return; } + if (msg.type === 'sources') onSources(msg); + else if (msg.type === 'config') onConfig(msg); + else if (msg.type === 'data') onData(msg); + else if (msg.type === 'stats') onStats(msg); + // Debug-specific messages + else if (msg.type === 'connected') onMarteConnected(); + else if (msg.type === 'disconnected') onMarteDisconnected(); + else if (msg.type === 'log') onLog(msg); + else if (msg.type === 'response') onResponse(msg); + else if (msg.type === 'tree_node') { try { onTreeNode(JSON.parse(msg.data)); } catch(e) { console.error('tree_node error:', e, msg.data); appendLog('sys','ERROR','Tree render error: '+e.message); } } + else if (msg.type === 'text_line') onTextLine(msg.data); + else if (msg.type === 'service_config') onServiceConfig(msg); + else if (msg.type === 'forced_state') onForcedState(msg); + else if (msg.type === 'traced_state') onTracedState(msg); + }; +} + +/* ════════════════════════════════════════════════════════════════ + Status LED + ════════════════════════════════════════════════════════════════ */ +function setStatus(s, t) { + document.getElementById('status-led').className = s; + document.getElementById('status-text').textContent = t; +} +setInterval(() => { + const tsEl = document.getElementById('sb-tsage'); + if (ws && ws.readyState === WebSocket.OPEN && lastDataAt > 0) { + const age = performance.now() - lastDataAt; + // Compute minimum lag: newest buffer timestamp vs browser wall clock. + let tsAge = null; + const wallNow = Date.now() / 1000; + Object.values(buffers).forEach(buf => { + if (buf.size === 0) return; + const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + const a = wallNow - newest; + if (tsAge === null || a < tsAge) tsAge = a; + }); + if (tsEl && tsAge !== null) { + const ms = tsAge * 1000; + tsEl.textContent = '| lag: ' + (ms < 1000 ? ms.toFixed(0) + 'ms' : tsAge.toFixed(2) + 's'); + } else if (tsEl) { + tsEl.textContent = ''; + } + if (age > 1000) setStatus('orange', 'No data for ' + (age / 1000).toFixed(1) + 's'); + else setStatus('green', 'Streaming'); + } else if (tsEl) { + tsEl.textContent = ''; + } +}, 500); + +/* ════════════════════════════════════════════════════════════════ + Config handler + ════════════════════════════════════════════════════════════════ */ +function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); } +function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; } + +function onConfig(msg) { + const sid = msg.sourceId; + if (!sid) return; + // Ensure source exists (may arrive before 'sources' message in some edge cases). + if (!sourcesMap[sid]) { + sourcesMap[sid] = { id: sid, label: sid, addr: '', state: 'connected', signals: [] }; + } + const src = sourcesMap[sid]; + const newSigs = msg.signals || []; + const oldSigs = src.signals || []; + const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0); + const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i])); + src.signals = newSigs; + if (changed) { + const prefix = sid + ':'; + // Build the complete set of buffer keys the new config requires. + const newKeys = new Set(); + const oldFp = {}; + oldSigs.forEach(s => { oldFp[s.name] = fp(s); }); + newSigs.forEach(sig => { + // All signals (scalar, temporal, or snapshot-waveform arrays) use the base key. + // Non-temporal arrays (n>1, timeMode=Packet=0) receive a snapshot waveform + // from the hub under the base signal name, not element-indexed keys. + newKeys.add(prefix + sig.name); + }); + // Build set of base signal names currently traced so their buffers survive + // config-cleanup even if the synthesised config uses different canonical names. + const tracedBaseNames = new Set(); + for (const n of tracedSet) tracedBaseNames.add(n.replace(/\[\d+\]$/, '')); + // Remove buffers for signals that are no longer present and not currently traced. + Object.keys(buffers).forEach(k => { + if (k.startsWith(prefix) && !newKeys.has(k)) { + const sigName = k.slice(prefix.length); + if (!tracedBaseNames.has(sigName)) delete buffers[k]; + } + }); + // Create buffers only for signals that are new or whose metadata changed. + newSigs.forEach(sig => { + const n = numElements(sig); + const base = prefix + sig.name; + const sigCap = bufferCapForRate(sig.samplingRate); + const metaChanged = oldFp[sig.name] !== fp(sig); + if (isTemporal(sig)) { + if (metaChanged || !buffers[base]) buffers[base] = makeBuffer(TEMPORAL_CAP); + } else { + // Both scalar (n==1) and snapshot-waveform (n>1, timeMode=Packet) arrays + // use a single buffer under the base key. The snapshot case stores + // n entries per hub tick (t=element index, v=value) — TEMPORAL_CAP keeps + // the last ~600 s worth of sweeps at 30 Hz × 1000 elements. + const cap = n > 1 ? TEMPORAL_CAP : sigCap; + if (metaChanged || !buffers[base]) buffers[base] = makeBuffer(cap); + } + }); + if (trig.signal && trig.signal.startsWith(prefix)) { + trigDisarm(); trig.snapshot = null; trig.prevVal = null; + } + zoomHistory.length = 0; + document.getElementById('btn-zoom-back').style.display = 'none'; + } + buildSidebar(); + buildTrigSignalSelect(); +} + +/* ════════════════════════════════════════════════════════════════ + Data handler + ════════════════════════════════════════════════════════════════ */ +function onData(msg) { + lastDataAt = performance.now(); + const sigs = msg.signals; if (!sigs) return; + Object.keys(sigs).forEach(key => { + const buf = buffers[key]; if (!buf) return; + const sd = sigs[key]; if (!sd || !sd.t || !sd.v) return; + const len = Math.min(sd.t.length, sd.v.length); + for (let i = 0; i < len; i++) pushBuffer(buf, sd.t[i], sd.v[i]); + }); + // Increment data generation counter so render loop knows data changed + _dataGen++; + if (trig.enabled && trig.armed && trig.signal) checkTrigger(sigs); + if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec()) + finaliseTriggerCapture(); + if (!trig.enabled) { + plots.forEach(p => { + if (globalPause) return; + if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true; + }); + } +} + +/* ════════════════════════════════════════════════════════════════ + Binary data handler — parses compact binary frames from Go backend. + Wire format (little-endian): + uint8 version (1) + uint8 sourceIdLen + UTF-8 sourceId + uint32 numSignals + for each signal: + uint16 keyLen + UTF-8 key (relative to source) + uint32 pairCount N + float64[N] t values + float64[N] v values + ════════════════════════════════════════════════════════════════ */ +function onBinaryData(buf) { + lastDataAt = performance.now(); + const dv = new DataView(buf); + let off = 0; + if (dv.getUint8(off) !== 1) return; + off += 1; + + const srcIdLen = dv.getUint8(off); off += 1; + const srcId = new TextDecoder().decode(new Uint8Array(buf, off, srcIdLen)); + off += srcIdLen; + const prefix = srcId + ':'; + + const numSigs = dv.getUint32(off, true); off += 4; + + // Collect trigger-signal values for inline check + let trigVals = null; + + for (let s = 0; s < numSigs; s++) { + const keyLen = dv.getUint16(off, true); off += 2; + const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen)); + off += keyLen; + const fullKey = prefix + key; + + const n = dv.getUint32(off, true); off += 4; + let bufObj = buffers[fullKey]; + if (!bufObj) { + bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP); + buffers[fullKey] = bufObj; + } + // If the buffer was created before the CONFIG message arrived (or with an + // underestimated capacity), grow it now that we know the signal's rate. + const detectedRate = getKeySamplingRate(fullKey); + if (detectedRate > 0) { + const needed = bufferCapForRate(detectedRate); + if (needed > bufObj.cap) { bufObj = growBuffer(bufObj, needed); buffers[fullKey] = bufObj; } + } + + // Read t and v values in one pass (v array starts at off + n*8) + const tOff = off, vOff = off + n * 8; + for (let i = 0; i < n; i++) { + pushBuffer(bufObj, dv.getFloat64(tOff + i * 8, true), dv.getFloat64(vOff + i * 8, true)); + } + off += n * 16; // skip both t and v arrays + + // Capture trigger signal values + if (trig.enabled && trig.armed && fullKey === trig.signal) { + trigVals = { t: new Float64Array(n), v: new Float64Array(n) }; + for (let i = 0; i < n; i++) { + trigVals.t[i] = dv.getFloat64(tOff + i * 8, true); + trigVals.v[i] = dv.getFloat64(vOff + i * 8, true); + } + } + } + + // Trigger check — pass as keyed object matching checkTrigger's expected format + if (trigVals) checkTrigger({ [trig.signal]: trigVals }); + + if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec()) + finaliseTriggerCapture(); + + if (!trig.enabled) { + _dataGen++; + plots.forEach(p => { + if (globalPause) return; + if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true; + }); + } +} + +function checkTrigger(sigs) { + const sd = sigs[trig.signal]; if (!sd || !sd.v || !sd.v.length) return; + for (let i = 0; i < sd.v.length; i++) { + const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur; + if (prev === null) continue; + const e = trig.edge, thr = trig.threshold; + if ((e === 'rising' && prev < thr && cur >= thr) || + (e === 'falling' && prev > thr && cur <= thr) || + (e === 'both' && ((prev < thr && cur >= thr) || (prev > thr && cur <= thr)))) { + fireTrigger(sd.t ? sd.t[i] : Date.now() / 1000); break; + } + } +} +function fireTrigger(t) { + // Clear the previous snapshot here (not in trigArm) so the last waveform + // and trigger marker stay visible while the trigger is rearmed and waiting. + trig.snapshot = null; trig.armed = false; trig.collecting = true; trig.trigTime = t; + updateTrigStatusBadge('waiting'); setAllCardsCollecting(true); +} +function finaliseTriggerCapture() { + trig.collecting = false; + const t0 = trig.trigTime - trigPreSec(), t1 = trig.trigTime + trigPostSec(); + const snap = {}; + Object.keys(buffers).forEach(key => { + const sl = getBufferSliceRange(buffers[key], t0, t1); + if (sl.t.length > 0) snap[key] = sl; + }); + // Tag snapshot with the window parameters captured at trigger time so that + // the render loop uses the correct bounds even if UI controls are changed later. + snap._preS = trigPreSec(); + snap._postS = trigPostSec(); + trig.snapshot = snap; + setAllCardsCollecting(false); updateTrigStatusBadge('triggered'); + showRearmBtn(trig.mode === 'single'); + showStopBtn(trig.mode === 'normal'); + // Show cursor button now that snapshot exists (positions preserved from before) + updateCursorBtnVisibility(); + plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); + if (trig.mode === 'normal' && !trig.stopped) + setTimeout(() => { if (trig.enabled && trig.mode === 'normal' && !trig.stopped) trigArm(); }, 200); + + // Upgrade the snapshot in the background with full-resolution ring data. + // The push buffer has min Δt ≈ 16 µs; the ring provides ≈ 1.65 µs. + upgradeTriggerSnapshot(t0, t1, trig.trigTime); +} + +// Fetches full-resolution ring data for the trigger window and replaces the +// push-buffer entries in trig.snapshot, then re-renders all plots. +async function upgradeTriggerSnapshot(t0, t1, capturedTrigTime) { + // Small delay so the ring receives the last post-trigger samples + // (ring write period ≈ 33 ms; 120 ms gives ≥ 3 ticks of margin). + await new Promise(r => setTimeout(r, 120)); + // Abort if a new trigger has fired since we started. + if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return; + + const keys = Object.keys(buffers); + if (!keys.length) return; + + const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`; + let ringSignals; + try { + const resp = await fetch(url); + if (!resp.ok) return; + const json = await resp.json(); + ringSignals = json && json.signals; + } catch (e) { + console.warn('trigger snapshot ring upgrade failed:', e); + return; + } + + // Abort if the snapshot was replaced while we were fetching. + if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return; + + let updated = false; + Object.entries(ringSignals || {}).forEach(([key, sd]) => { + if (!sd || !sd.t || !sd.t.length) return; + trig.snapshot[key] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) }; + updated = true; + }); + if (updated) plots.forEach(p => { p.needsRedraw = true; }); +} +function trigArm() { + // Do NOT clear trig.snapshot here — keep the last waveform and trigger marker + // visible while waiting for the next event. fireTrigger() clears it when a new + // trigger actually fires. + trig.armed = true; trig.collecting = false; trig.prevVal = null; + showRearmBtn(false); updateTrigStatusBadge('armed'); + updateCursorBtnVisibility(); + plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); +} +function trigDisarm() { + trig.armed = false; trig.collecting = false; trig.trigTime = null; trig.stopped = false; + setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle'); +} +function setAllCardsCollecting(on) { + document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on)); +} +function updateTrigStatusBadge(state) { + const el = document.getElementById('trig-status-badge'); + el.className = state; + el.textContent = { idle: 'IDLE', armed: 'ARMED', waiting: 'COLLECTING', triggered: 'TRIGGERED' }[state] || 'IDLE'; +} +function showRearmBtn(v) { document.getElementById('btn-trig-rearm').style.display = v ? 'inline-block' : 'none'; } +function showStopBtn(v) { document.getElementById('btn-trig-stop').style.display = v ? 'inline-block' : 'none'; } +function updateStopBtn() { + const btn = document.getElementById('btn-trig-stop'); + btn.textContent = trig.stopped ? 'Resume' : 'Stop'; +} + +/* ════════════════════════════════════════════════════════════════ + Circular buffer + ════════════════════════════════════════════════════════════════ */ +function makeBuffer(cap) { + cap = cap || DEFAULT_CAP; + return { t: new Float64Array(cap), v: new Float64Array(cap), head: 0, size: 0, cap }; +} +function pushBuffer(buf, t, v) { + buf.t[buf.head] = t; buf.v[buf.head] = v; + buf.head = (buf.head + 1) % buf.cap; + if (buf.size < buf.cap) buf.size++; +} + +// Binary-search range slice of circular buffer — O(log n + window_size) +function getBufferSliceRange(buf, t0, t1) { + if (buf.size === 0) return { t: new Float64Array(0), v: new Float64Array(0) }; + const { cap, size, head } = buf; + const start = (size === cap) ? head : 0; + const physAt = k => (start + k) % cap; + + let lo = 0, hi = size; + while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; } + const kStart = lo; + lo = kStart; hi = size; + while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; } + const kEnd = lo, len = kEnd - kStart; + if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) }; + + const outT = new Float64Array(len), outV = new Float64Array(len); + const physStart = physAt(kStart), tail = cap - physStart; + if (tail >= len) { + outT.set(buf.t.subarray(physStart, physStart + len)); + outV.set(buf.v.subarray(physStart, physStart + len)); + } else { + outT.set(buf.t.subarray(physStart, physStart + tail)); + outT.set(buf.t.subarray(0, len - tail), tail); + outV.set(buf.v.subarray(physStart, physStart + tail)); + outV.set(buf.v.subarray(0, len - tail), tail); + } + return { t: outT, v: outV }; +} + +// Like getBufferSliceRange but also includes the nearest point just outside each +// boundary so that a line is always drawn across the visible area even when the +// zoom window contains only 0 or 1 samples. +function getBufferSliceRangeWithBrackets(buf, t0, t1) { + if (buf.size === 0) return { t: new Float64Array(0), v: new Float64Array(0) }; + const { cap, size, head } = buf; + const start = (size === cap) ? head : 0; + const physAt = k => (start + k) % cap; + + let lo = 0, hi = size; + while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; } + const kStart = lo; + lo = kStart; hi = size; + while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; } + const kEnd = lo; + + // Expand by one on each side for bracketing points. + const kFrom = Math.max(0, kStart - 1); + const kTo = Math.min(size, kEnd + 1); + const len = kTo - kFrom; + if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) }; + + const outT = new Float64Array(len), outV = new Float64Array(len); + for (let i = 0; i < len; i++) { + const idx = physAt(kFrom + i); + outT[i] = buf.t[idx]; outV[i] = buf.v[idx]; + } + return { t: outT, v: outV }; +} + +// Supplement sparse fetched signal data ({t, v} Float64Arrays) with the nearest +// bracketing points from the local circular buffer, so lines are always drawn +// across the zoom window even if the server returned 0 or 1 points. +function supplementWithBrackets(sd, buf, t0, t1) { + if (!buf || buf.size === 0) return sd; + if (sd && sd.t.length >= 2) return sd; // already enough points + const { size, head, cap } = buf; + const start = (size === cap) ? head : 0; + const physAt = k => (start + k) % cap; + + let lo = 0, hi = size; + while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; } + const kStart = lo; + lo = kStart; hi = size; + while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; } + const kEnd = lo; + + const leftK = kStart > 0 ? kStart - 1 : -1; + const rightK = kEnd < size ? kEnd : -1; + + const tArr = [], vArr = []; + if (leftK >= 0) { tArr.push(buf.t[physAt(leftK)]); vArr.push(buf.v[physAt(leftK)]); } + if (sd) { for (let i = 0; i < sd.t.length; i++) { tArr.push(sd.t[i]); vArr.push(sd.v[i]); } } + if (rightK >= 0) { tArr.push(buf.t[physAt(rightK)]); vArr.push(buf.v[physAt(rightK)]); } + if (tArr.length === 0) return sd; + return { t: Float64Array.from(tArr), v: Float64Array.from(vArr) }; +} +// getGlobalNow returns the reference "now" for the rolling window. +// Always anchors to the newest timestamp found in any buffer so the rolling +// window tracks real data regardless of any clock skew between the Go server +// and the browser. Falls back to Date.now()/1000 only when all buffers are +// empty (no data yet received). +function getGlobalNow() { + let latest = -Infinity; + Object.values(buffers).forEach(buf => { + if (buf.size === 0) return; + const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + if (newestT > latest) latest = newestT; + }); + return isFinite(latest) ? latest : Date.now() / 1000; +} + +// getBufferNow returns the "now" anchor for a single buffer — the buffer's own +// newest timestamp. This avoids cross-signal interference when signals have +// different timescales or update rates. +function getBufferNow(buf) { + if (buf.size === 0) return Date.now() / 1000; + return buf.t[(buf.head - 1 + buf.cap) % buf.cap]; +} + +function getBufferSlice(buf) { + const now = getBufferNow(buf); + return getBufferSliceRange(buf, now - windowSec, now); +} + +// Binary-search slice of a sorted contiguous Float64Array pair +function sliceTypedArrayRange(t, v, t0, t1) { + let lo = 0, hi = t.length; + while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] < t0) lo = m + 1; else hi = m; } + const s = lo; lo = s; hi = t.length; + while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] <= t1) lo = m + 1; else hi = m; } + return { t: t.subarray(s, lo), v: v.subarray(s, lo) }; +} + + +// Return the configured samplingRate for a buffer key. +// Temporal array signals have a meaningful SamplingRate; scalars return 0. +// Used to prefer high-freq signals as the master time grid regardless of trace order. +function getKeySamplingRate(key) { + // key format: "sourceId:signalName" or "sourceId:signalName[i]" + for (const src of Object.values(sourcesMap)) { + const prefix = src.id + ':'; + if (!key.startsWith(prefix)) continue; + const localKey = key.slice(prefix.length); + const direct = (src.signals || []).find(s => s.name === localKey); + if (direct) return direct.samplingRate || 0; + const sig = (src.signals || []).find(s => localKey.startsWith(s.name + '[')); + if (sig) return sig.samplingRate || 0; + } + return 0; +} + +// Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default). +function makeSeriesPath(key) { + const style = getSigStyle(key); + if (style.dash === 'solid') return null; + const dashPat = style.dash === 'dashed' ? [6, 4] : [2, 3]; + return (u, si) => { + const xd = u.data[0], yd = u.data[si]; + if (!xd || !yd || !u.bbox) return { stroke: null, fill: null }; + const { ctx, bbox } = u; + ctx.save(); + ctx.beginPath(); + ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); + ctx.clip(); + ctx.strokeStyle = style.color; + ctx.lineWidth = style.width; + ctx.setLineDash(dashPat); + ctx.lineJoin = 'round'; + ctx.beginPath(); + let moved = false; + for (let i = 0; i < xd.length; i++) { + if (yd[i] == null) { moved = false; continue; } + const cx = u.valToPos(xd[i], 'x', true); + const cy = u.valToPos(yd[i], 'y', true); + if (!moved) { ctx.moveTo(cx, cy); moved = true; } + else ctx.lineTo(cx, cy); + } + ctx.stroke(); + ctx.restore(); + return { stroke: null, fill: null }; // tell uPlot not to draw anything on top + }; +} + +/* ════════════════════════════════════════════════════════════════ + LTTB Web Worker — offloads decimation off the main thread. + Cache key: "::::" + On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate). + On cache-miss → render falls back to sync lttb once (first zoom render only), + then worker takes over for subsequent updates. + ════════════════════════════════════════════════════════════════ */ +const lttbCache = new Map(); // key → {t, v} +const lttbPending = new Set(); // keys currently in-flight + +let _lttbWorker = null; +try { + _lttbWorker = new Worker('lttb-worker.js'); + _lttbWorker.onmessage = function({ data: { id, t, v } }) { + lttbPending.delete(id); + lttbCache.set(id, { t, v }); + // Invalidate and redraw the owning plot. + const plotId = parseInt(id.split(':')[0], 10); + const p = plots.find(q => q.id === plotId); + if (p) { p.needsRedraw = true; } + }; + _lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e); +} catch(e) { + console.warn('[lttb-worker] unavailable, using sync fallback:', e); +} + +// Submit a LTTB job to the worker (or run sync if worker unavailable). +// Returns cached {t, v} if fresh, null if a worker job was just submitted, +// or a sync result if the worker is unavailable. +function lttbAsync(cacheKey, t, v, threshold) { + const cached = lttbCache.get(cacheKey); + if (cached) return cached; // cache hit — use immediately + + if (!lttbPending.has(cacheKey)) { + lttbPending.add(cacheKey); + if (_lttbWorker) { + // Send copies so the main thread retains the originals. + const tCopy = new Float64Array(t); + const vCopy = new Float64Array(v); + _lttbWorker.postMessage({ id: cacheKey, t: tCopy, v: vCopy, threshold }, + [tCopy.buffer, vCopy.buffer]); + } else { + // Synchronous fallback (worker unavailable). + const result = lttb(t, v, threshold); + lttbCache.set(cacheKey, result); + lttbPending.delete(cacheKey); + return result; + } + } + return null; // worker job in-flight — caller should use fallback +} + +// Evict stale LTTB cache entries for a plot (call when zoom range changes). +function lttbCacheEvict(plotId) { + const prefix = plotId + ':'; + for (const k of [...lttbCache.keys()]) { + if (k.startsWith(prefix)) lttbCache.delete(k); + } + for (const k of [...lttbPending]) { + if (k.startsWith(prefix)) lttbPending.delete(k); + } +} + +// LTTB decimation — O(n). Returns {t, v} unchanged when len ≤ threshold. +function lttb(t, v, threshold) { + const len = t.length; + if (len <= threshold) return { t, v }; + const outT = new Float64Array(threshold), outV = new Float64Array(threshold); + outT[0] = t[0]; outV[0] = v[0]; + outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1]; + const every = (len - 2) / (threshold - 2); + let a = 0; + for (let i = 0; i < threshold - 2; i++) { + const avgS = Math.floor((i + 1) * every) + 1, avgE = Math.min(Math.floor((i + 2) * every) + 1, len); + let avgT = 0, avgV = 0, n = 0; + for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; } + if (n) { avgT /= n; avgV /= n; } + const rS = Math.floor(i * every) + 1, rE = Math.min(Math.floor((i + 1) * every) + 1, len); + let maxA = -1, next = rS; + const aT = t[a], aV = v[a]; + for (let j = rS; j < rE; j++) { + const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV)); + if (area > maxA) { maxA = area; next = j; } + } + outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next; + } + return { t: outT, v: outV }; +} + +/* ════════════════════════════════════════════════════════════════ + Hybrid zoom: hi-res data fetched from /api/zoom on demand + ════════════════════════════════════════════════════════════════ */ +function cancelZoomFetch() { + if (_zoomFetchTimer !== null) { clearTimeout(_zoomFetchTimer); _zoomFetchTimer = null; } +} +function scheduleZoomFetch(t0, t1) { + cancelZoomFetch(); + _zoomFetchTimer = setTimeout(() => { _zoomFetchTimer = null; doZoomFetch(t0, t1); }, 150); +} + +async function doZoomFetch(t0, t1) { + // Collect all signal keys across all plots; each key encodes sourceId as prefix. + const allKeys = new Set(); + plots.forEach(p => p.traces.forEach(k => allKeys.add(k))); + if (allKeys.size === 0) return; + + const targetPts = Math.max(400, (plots.find(p => p.uplot)?.uplot?.width || 600) * 2); + const url = `/api/zoom?t0=${t0}&t1=${t1}&n=${targetPts}&signals=${[...allKeys].join(',')}`; + let fetched; + try { + const resp = await fetch(url); + if (!resp.ok) return; + fetched = await resp.json(); + } catch (e) { console.warn('zoom fetch:', e); return; } + + const sigs = fetched && fetched.signals; + if (!sigs) return; + + // Convert arrays from JSON to Float64Array and store per-plot. + const merged = {}; + Object.entries(sigs).forEach(([k, sd]) => { + if (!sd || !sd.t || !sd.v) return; + merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) }; + }); + + plots.forEach(p => { + // Only store if this plot's range still matches what we fetched. + if (!p.xRange || Math.abs(p.xRange[0] - t0) > 1e-9 || Math.abs(p.xRange[1] - t1) > 1e-9) return; + const plotSigs = {}; + p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; }); + if (Object.keys(plotSigs).length > 0) { + zoomData[p.id] = { signals: plotSigs, t0, t1 }; + p.needsRedraw = true; + } + }); +} + +// Build uPlot data arrays from server-fetched hi-res signal data. +// Sparse signals (0 or 1 pts in range) are supplemented with local buffer +// bracket points so a line is always drawn across the visible area. +function buildDataFromFetched(p, fetchedSignals, targetPts) { + const t0 = p.xRange ? p.xRange[0] : -Infinity; + const t1 = p.xRange ? p.xRange[1] : Infinity; + + let masterKey = p.traces[0], masterCount = -1, masterRate = -1; + for (const key of p.traces) { + let sd = fetchedSignals[key]; + if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1); + if (!sd || !sd.t.length) continue; + const rate = getKeySamplingRate(key); + if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) { + masterRate = rate; masterCount = sd.t.length; masterKey = key; + } + } + + let masterSd = fetchedSignals[masterKey]; + if (!masterSd || masterSd.t.length < 2) + masterSd = supplementWithBrackets(masterSd, buffers[masterKey], t0, t1); + if (!masterSd || !masterSd.t.length) + return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; + + const dec = lttb(masterSd.t, masterSd.v, targetPts); + const sharedT = dec.t; + const yArrays = []; + for (const key of p.traces) { + if (key === masterKey) { yArrays.push(dec.v); continue; } + let sd = fetchedSignals[key]; + if (!sd || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1); + if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; } + yArrays.push(resampleLinear(sd.t, sd.v, sharedT)); + } + return [sharedT, ...applyVScaleNorm(p, yArrays)]; +} + +/* ════════════════════════════════════════════════════════════════ + uPlot helpers + ════════════════════════════════════════════════════════════════ */ +// ─── Time formatting helpers ────────────────────────────────────────────────── + +// Returns the span (in seconds) of the currently visible x-axis across all plots. +// Falls back to windowSec when no uPlot instances exist yet. +function currentXSpan() { + for (const p of plots) { + if (p.uplot) { + const s = p.uplot.scales.x; + if (s && s.min != null && s.max != null) return Math.abs(s.max - s.min); + } + } + return windowSec; +} + +// Format a signed duration (seconds) auto-selecting s / ms / µs / ns based on +// refSpan (e.g. the visible x-range or the value itself). +// sign = '+' prefix only when showSign is true (default false for ΔT display). +function fmtDuration(sec, refSpan, showSign) { + const abs = Math.abs(sec); + const sign = showSign ? (sec < 0 ? '−' : '+') : (sec < 0 ? '−' : ''); + if (refSpan < 1e-6) { // nanosecond range + return sign + (abs * 1e9).toFixed(1) + ' ns'; + } else if (refSpan < 1e-3) { // microsecond range + return sign + (abs * 1e6).toFixed(3) + ' µs'; + } else if (refSpan < 1) { // millisecond range + return sign + (abs * 1e3).toFixed(3) + ' ms'; + } else { // second range + return sign + abs.toFixed(6) + ' s'; + } +} + +// Format a Unix-seconds timestamp → HH:MM:SS.fraction +// The number of sub-second digits adapts to the visible x-range span. +function fmtLiveTime(v, span) { + const d = new Date(v * 1000); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const frac = v - Math.floor(v); // sub-second part, full float64 precision + if (span < 1e-6) { + // show 9 decimal places (ns precision) + const ns = Math.round(frac * 1e9); + return hh + ':' + mm + ':' + ss + '.' + String(ns).padStart(9, '0'); + } else if (span < 1e-3) { + // show 6 decimal places (µs precision) + const us = Math.round(frac * 1e6); + return hh + ':' + mm + ':' + ss + '.' + String(us).padStart(6, '0'); + } else if (span < 1) { + // show 3 decimal places (ms precision) — tick labels only show ms + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return hh + ':' + mm + ':' + ss + '.' + ms; + } else { + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return hh + ':' + mm + ':' + ss + '.' + ms; + } +} + +// Format Unix seconds → HH:MM:SS.fraction (used for live x-axis ticks) +// Precision adapts to the visible x-range (via u.scales.x.{min,max}). +function fmtLiveTick(u, vals) { + const span = (u.scales.x && u.scales.x.max != null) + ? Math.abs(u.scales.x.max - u.scales.x.min) : windowSec; + return vals.map(v => v == null ? '' : fmtLiveTime(v, span)); +} + +// Format relative seconds → auto-scaled unit (used for trigger x-axis ticks) +// Unit (s/ms/µs/ns) is determined by the visible x-range span. +function fmtTrigTick(u, vals) { + const span = (u.scales.x && u.scales.x.max != null) + ? Math.abs(u.scales.x.max - u.scales.x.min) : 1; + return vals.map(v => v == null ? '' : fmtDuration(v, span, true)); +} + +// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold +// line on any plot that shows the trigger signal. +function drawTriggerMarker(u, p) { + if (!trig.enabled || !trig.snapshot) return; + const { ctx, bbox } = u; + if (!bbox) return; + const x = u.valToPos(0, 'x', true); + if (x < bbox.left || x > bbox.left + bbox.width) return; + const px = Math.round(x); + ctx.save(); + // Thin dashed vertical line at t=0 + ctx.strokeStyle = 'rgba(203,166,247,0.7)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(px, bbox.top); + ctx.lineTo(px, bbox.top + bbox.height); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(203,166,247,0.7)'; + ctx.font = 'bold 9px monospace'; + ctx.textBaseline = 'top'; + ctx.fillText('T', px + 3, bbox.top + 2); + // Horizontal threshold line — only on plots that contain the trigger signal + if (p && trig.signal && p.traces.includes(trig.signal)) { + // Normalize the raw threshold to this plot's vscale for the trigger signal. + const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null; + let threshNorm = trig.threshold; + if (tvs) { + const dv = tvs._resolvedDiv || tvs.divValue || 1; + const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0); + const sp = tvs.screenPos || 0; + threshNorm = (trig.threshold - ofs) / dv + sp; + } + const y = u.valToPos(threshNorm, 'y', true); + if (y >= bbox.top && y <= bbox.top + bbox.height) { + const py = Math.round(y); + ctx.strokeStyle = 'rgba(203,166,247,0.45)'; + ctx.lineWidth = 0.75; + ctx.setLineDash([3, 4]); + ctx.beginPath(); + ctx.moveTo(bbox.left, py); + ctx.lineTo(bbox.left + bbox.width, py); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(203,166,247,0.45)'; + ctx.font = '9px monospace'; + ctx.textBaseline = 'bottom'; + ctx.fillText(trig.threshold.toPrecision(4), bbox.left + 4, py - 1); + } + } + ctx.restore(); +} + +// Linearly interpolate series value at time t from uPlot's current rendered data. +function interpAtTime(u, si, t) { + const td = u.data[0], vd = u.data[si]; + if (!td || td.length === 0 || !vd) return null; + let lo = 0, hi = td.length - 1; + while (lo < hi) { const m = (lo + hi) >> 1; if (td[m] < t) lo = m + 1; else hi = m; } + if (lo === 0) return vd[0] ?? null; + const t0 = td[lo - 1], t1 = td[lo]; + const v0 = vd[lo - 1], v1 = vd[lo]; + if (v0 == null || v1 == null) return v0 ?? v1 ?? null; + return v0 + (t - t0) / (t1 - t0) * (v1 - v0); +} + +// Draw signal names rotated -90° in the Y-axis strip for digital/mixed mode. +// Each name is centred vertically within its band and clipped so it never bleeds +// into adjacent bands. The Y-axis size must be reduced accordingly (see createUPlotOpts). +function drawBandLabels(u, p) { + if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return; + const n = p.traces.length; + if (n === 0) return; + const { ctx, bbox } = u; + const dpr = window.devicePixelRatio || 1; + const bandH = 8 / n; + // The Y-axis strip is 20 CSS-px wide → 20*dpr physical px; its centre sits at + // bbox.left - 10*dpr. (Must match the axis `size` value below.) + const axisW = 20 * dpr; + const xCenter = bbox.left - axisW / 2; + + ctx.save(); + ctx.font = `${9 * dpr}px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + for (let i = 0; i < n; i++) { + const key = p.traces[i]; + const color = getSigStyle(key).color; + + // Band vertical extent in physical (canvas) pixels – topPx < botPx. + const topPx = u.valToPos(4 - i * bandH, 'y', true); + const botPx = u.valToPos(4 - (i + 1) * bandH, 'y', true); + const bandPx = botPx - topPx; // positive, increases downward + const centerPx = (topPx + botPx) / 2; + + // Use the last two dot-separated components of the signal name (no source prefix). + const rawName = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = rawName.split('.'); + const label = parts.length > 2 ? parts.slice(-2).join('.') : rawName; + + ctx.save(); + ctx.fillStyle = color; + // Clip to this band's column in the axis strip so long labels don't bleed. + ctx.beginPath(); + ctx.rect(bbox.left - axisW, topPx + 1, axisW, bandPx - 2); + ctx.clip(); + // Rotate around the label's centre point. + ctx.translate(xCenter, centerPx); + ctx.rotate(-Math.PI / 2); // reads bottom-to-top + ctx.fillText(label, 0, 0); + ctx.restore(); + } + + ctx.restore(); +} + +// Draw horizontal separator lines between signal bands in digital/mixed mode. +function drawBandSeparators(u, p) { + if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return; + const n = p.traces.length; + if (n < 2) return; + const { ctx, bbox } = u; + const dpr = window.devicePixelRatio || 1; + const bandH = 8 / n; + ctx.save(); + ctx.strokeStyle = 'rgba(127,132,156,0.25)'; + ctx.lineWidth = dpr; + for (let i = 1; i < n; i++) { + const yNorm = 4 - i * bandH; // boundary between band i-1 and band i + const yPx = u.valToPos(yNorm, 'y', true); + ctx.beginPath(); + ctx.moveTo(bbox.left, yPx); + ctx.lineTo(bbox.left + bbox.width, yPx); + ctx.stroke(); + } + ctx.restore(); +} + +// Redraw the active signal's line on top of all series with a wider stroke, so it +// visually appears in the foreground regardless of series draw order. +function drawActiveSeries(u, p) { + if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return; + const activeKey = plotActiveSignal[p.id]; + if (!activeKey) return; + const idx = p.traces.indexOf(activeKey); + if (idx < 0) return; + const xs = u.data[0]; + const ys = u.data[idx + 1]; // +1 because index 0 is time + if (!xs || !ys) return; + const style = getSigStyle(activeKey); + const dpr = window.devicePixelRatio || 1; + const { ctx } = u; + ctx.save(); + ctx.strokeStyle = style.color; + ctx.lineWidth = style.width * 2 * dpr; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.beginPath(); + let started = false; + for (let i = 0; i < xs.length; i++) { + const yv = ys[i]; + if (yv == null || !isFinite(yv)) { started = false; continue; } + const xPx = u.valToPos(xs[i], 'x', true); + const yPx = u.valToPos(yv, 'y', true); + if (!started) { ctx.moveTo(xPx, yPx); started = true; } + else ctx.lineTo(xPx, yPx); + } + ctx.stroke(); + ctx.restore(); +} + +// Draw offset position markers (right-pointing triangles) on the left edge of the plot +// for each signal. Active signal marker is larger and outlined in white. +function drawOffsetMarkers(u, p) { + if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return; + const { ctx, bbox } = u; + const dpr = window.devicePixelRatio || 1; + + p.traces.forEach(key => { + const vs = sigVScale[p.id + ':' + key]; + const screenPos = vs ? (vs.screenPos || 0) : 0; + const yCtr = u.valToPos(screenPos, 'y', true); + const mH = (plotActiveSignal[p.id] === key ? 7 : 5) * dpr; + const mW = (plotActiveSignal[p.id] === key ? 10 : 7) * dpr; + if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return; + const isActive = plotActiveSignal[p.id] === key; + ctx.save(); + ctx.fillStyle = getSigStyle(key).color; + // Right-pointing triangle: tip at left edge of plot area, body extends left into Y-axis area + ctx.beginPath(); + ctx.moveTo(bbox.left + dpr, yCtr); + ctx.lineTo(bbox.left - mW + dpr, yCtr - mH); + ctx.lineTo(bbox.left - mW + dpr, yCtr + mH); + ctx.closePath(); + ctx.fill(); + if (isActive) { + ctx.strokeStyle = 'rgba(255,255,255,0.75)'; + ctx.lineWidth = dpr; + ctx.stroke(); + } + ctx.restore(); + }); +} + +// Draw custom marker shapes (square, cross, diamond) for all series in a plot. +// Circle markers are handled natively by uPlot's points option. +function drawSeriesMarkers(u, p) { + if (!u.bbox) return; + const { ctx, bbox } = u; + ctx.save(); + ctx.beginPath(); + ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); + ctx.clip(); + p.traces.forEach((key, idx) => { + const style = getSigStyle(key); + if (style.marker === 'none' || style.marker === 'circle') return; + const si = idx + 1; + const xd = u.data[0], yd = u.data[si]; + if (!xd || !yd) return; + const sz = style.markerSize; + ctx.strokeStyle = style.color; + ctx.fillStyle = style.color; + ctx.lineWidth = 1.5; + ctx.setLineDash([]); + for (let i = 0; i < xd.length; i++) { + if (yd[i] == null) continue; + const cx = u.valToPos(xd[i], 'x', true); + const cy = u.valToPos(yd[i], 'y', true); + if (cx < bbox.left || cx > bbox.left + bbox.width || + cy < bbox.top || cy > bbox.top + bbox.height) continue; + ctx.beginPath(); + if (style.marker === 'square') { + ctx.rect(cx - sz / 2, cy - sz / 2, sz, sz); ctx.fill(); + } else if (style.marker === 'cross') { + ctx.moveTo(cx - sz / 2, cy); ctx.lineTo(cx + sz / 2, cy); + ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx, cy + sz / 2); + ctx.stroke(); + } else if (style.marker === 'diamond') { + ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx + sz / 2, cy); + ctx.lineTo(cx, cy + sz / 2); ctx.lineTo(cx - sz / 2, cy); + ctx.closePath(); ctx.fill(); + } + } + }); + ctx.restore(); +} + +// Draw cursor A/B vertical lines and signal value labels (called from draw hook). +function drawCursorLines(u, p) { + if (cursors.mode !== 'on') return; + const { ctx, bbox } = u; + if (!bbox) return; + + const drawLine = (val, color, label) => { + if (val === null) return; + const x = u.valToPos(val, 'x', true); + if (x < bbox.left || x > bbox.left + bbox.width) return; + const px = Math.round(x); + ctx.save(); + // Clip to plot area + ctx.beginPath(); + ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); + ctx.clip(); + // Vertical dashed cursor line + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 4]); + ctx.beginPath(); + ctx.moveTo(px, bbox.top); + ctx.lineTo(px, bbox.top + bbox.height); + ctx.stroke(); + ctx.setLineDash([]); + // Cursor label at top + ctx.fillStyle = color; + ctx.font = 'bold 12px monospace'; + ctx.textBaseline = 'top'; + ctx.fillText(label, px + 14, bbox.top + 2); + // Per-trace: diamond at crossing point + value label + if (p) { + const activeKey = plotActiveSignal[p.id]; + p.traces.forEach((key, idx) => { + const isActive = key === activeKey || (!activeKey && p.traces.length === 1); + const DSZ = isActive ? 9 : 5; // diamond half-size in px + const vNorm = interpAtTime(u, idx + 1, val); + if (vNorm === null) return; + const cy = u.valToPos(vNorm, 'y', true); + if (cy < bbox.top || cy > bbox.top + bbox.height) return; + // Un-transform normalized value back to real units for display + // y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset + const vs = sigVScale[p.id + ':' + key]; + let vReal = vNorm; + if (vs) { + const dv = vs._resolvedDiv || vs.divValue || 1; + const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); + const sp = vs.screenPos || 0; + vReal = (vNorm - sp) * dv + ofs; + } + const tc = getSigStyle(key).color; + // Diamond marker at intersection + ctx.fillStyle = tc; + ctx.strokeStyle = tc; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px, cy - DSZ); + ctx.lineTo(px + DSZ, cy); + ctx.lineTo(px, cy + DSZ); + ctx.lineTo(px - DSZ, cy); + ctx.closePath(); + ctx.fill(); + // Value text next to diamond (real units) + const str = Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString(); + ctx.fillStyle = tc; + ctx.font = '11px monospace'; + const currentAlign = ctx.textAlign; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(str, px + DSZ + 4, cy); + ctx.textAlign = currentAlign; + }); + } + ctx.restore(); + }; + + drawLine(cursors.tA, 'rgba(137,220,235,0.85)', 'A'); + drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B'); +} + +// Compute the rolling-window anchor ("newest common timestamp") for a plot. +// Returns the min-of-max timestamp across ACTIVE sources contributing traces to p, +// so no live source shows a blank right edge. +// Sources whose newest timestamp lags the fastest source by more than windowSec are +// considered stale (disconnected / from a previous session) and are excluded, so they +// cannot anchor the rolling window far in the past. +function computePlotNow(p) { + const sourceNewest = {}; + p.traces.forEach(key => { + const colon = key.indexOf(':'); + if (colon < 0) return; + const srcId = key.slice(0, colon); + const buf = buffers[key]; + if (!buf || buf.size === 0) return; + const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + if (sourceNewest[srcId] === undefined || t > sourceNewest[srcId]) sourceNewest[srcId] = t; + }); + const srcVals = Object.values(sourceNewest); + if (srcVals.length === 0) return Date.now() / 1000; + const globalMax = Math.max(...srcVals); + // Keep only sources that have received data within the last windowSec. + const active = srcVals.filter(t => t >= globalMax - windowSec); + let now = active.length > 0 ? Math.min(...active) : globalMax; + if (!isFinite(now)) now = Date.now() / 1000; + return now; +} + +// Build uPlot opts for a given plot object +function makeUPlotOpts(p, inTrigMode) { + const isBanded = p.mode === 'digital' || p.mode === 'mixed'; + const seriesArr = [{}]; // time (index 0) + p.traces.forEach(key => { + const style = getSigStyle(key); + const pathsFn = isBanded ? null : makeSeriesPath(key); + const vs = isBanded ? getVScale(p.id, key) : null; + const isDigSig = p.mode === 'digital' || (p.mode === 'mixed' && vs && vs.digitalInMixed); + seriesArr.push({ + label: key, + stroke: style.color, + width: isBanded ? 1.5 : style.width, + points: { show: false }, + spanGaps: !isDigSig, + ...(pathsFn ? { paths: pathsFn } : {}), + }); + }); + + const xVals = inTrigMode ? (u, vals) => fmtTrigTick(u, vals) + : (u, vals) => fmtLiveTick(u, vals); + + return { + width: Math.max(p.div.clientWidth || 100, 50), + height: Math.max(p.div.clientHeight || 100, 50), + cursor: { + sync: { key: inTrigMode ? 'trig' : 'live', setSeries: false }, + drag: { x: true, y: false, setScale: true, uni: 20 }, + lock: false, + }, + select: { show: true }, + scales: { + x: (() => { + let xMin, xMax; + if (p.xRange) { + xMin = p.xRange[0]; xMax = p.xRange[1]; + } else { + const now = computePlotNow(p); + xMin = now - windowSec; xMax = now; + } + return { time: false, auto: false, min: xMin, max: xMax }; + })(), + y: { auto: false, min: -4.5, max: 4.5 }, + }, + series: seriesArr, + axes: [ + { + stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, + values: xVals, size: 36, + // Always produce exactly 10 horizontal divisions (11 evenly-spaced tick lines). + splits: (u, _ai, sMin, sMax) => { + const n = 10, span = sMax - sMin; + if (span === 0) return [sMin]; + return Array.from({ length: n + 1 }, (_, i) => sMin + span * i / n); + }, + }, + { + stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, + // In digital/mixed mode signal names are drawn as rotated text by drawBandLabels, + // so we only need a narrow strip (20 px) instead of the wide label column (60 px). + size: isBanded ? 20 : 60, + splits: () => { + if (isBanded && p.traces.length > 0) { + const n = p.traces.length, bandH = 8 / n; + return p.traces.map((_, i) => 4 - (i + 0.5) * bandH); + } + return [-4, -3, -2, -1, 0, 1, 2, 3, 4]; + }, + values: (u, vals) => { + if (isBanded && p.traces.length > 0) { + // Labels are drawn manually as rotated text; suppress uPlot's horizontal rendering. + return vals.map(() => ''); + } + const activeKey = plotActiveSignal[p.id]; + const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null; + if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1)); + const divValue = vs._resolvedDiv || vs.divValue || 1; + const offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); + const screenPos = vs.screenPos || 0; + return vals.map(v => v == null ? '' : _fmtVal((v - screenPos) * divValue + offset)); + }, + }, + ], + legend: { show: false }, + padding: [4, 4, 0, 0], + hooks: { + draw: [u => { drawBandLabels(u, p); drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }], + // Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated. + // uPlot fires setSelect → then immediately setScale (when drag.setScale:true). + // All programmatic setScale calls happen without a preceding setSelect, so the + // flag is false and onZoom is never called unintentionally. + setSelect: [(u) => { u._userZoom = (u.select.width > 0); }], + setScale: [(u, key) => { + if (key !== 'x' || !u._userZoom || !u._ready) return; + u._userZoom = false; + const { min, max } = u.scales.x; + if (min == null || max == null || max <= min) return; + onZoom(p.id, min, max); + }], + ready: [u => { u._ready = true; }], + }, + }; +} + +// Create (or recreate) the uPlot instance for a plot, mounting into p.div +function createUPlot(p) { + // Destroy previous instance + if (p.uplot) { p.uplot.destroy(); p.uplot = null; } + + const inTrigMode = trig.enabled && trig.snapshot !== null; + const opts = makeUPlotOpts(p, inTrigMode); + const data = buildUPlotData(p, inTrigMode); + + // Mount into the plot body div (clear previous uPlot DOM) + p.div.querySelectorAll('.uplot').forEach(el => el.remove()); + + p.uplot = new uPlot(opts, data, p.div); + + // ── Cursor drag: place or drag A/B cursors ────────────────────────────── + // - Near an existing cursor line (within CURSOR_SNAP_PX): drag to move it. + // - cursor mode A or B active: click/drag places that cursor anywhere. + // - Intercepts mousedown before uPlot zoom so the selection rect never shows. + const CURSOR_SNAP_PX = 8; + + function _cursorAtClientX(clientX) { + const rect = p.uplot.over.getBoundingClientRect(); + const { min, max } = p.uplot.scales.x; + const toX = val => rect.left + ((val - min) / (max - min)) * rect.width; + if (cursors.tA !== null && Math.abs(clientX - toX(cursors.tA)) <= CURSOR_SNAP_PX) return 'A'; + if (cursors.tB !== null && Math.abs(clientX - toX(cursors.tB)) <= CURSOR_SNAP_PX) return 'B'; + return null; + } + + function _cursorValFromEvent(e) { + const rect = p.uplot.over.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const { min, max } = p.uplot.scales.x; + return min + pct * (max - min); + } + + // Update pointer style based on what's under the mouse + p.uplot.over.addEventListener('mousemove', e => { + const snap = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null; + p.uplot.over.style.cursor = snap ? 'ew-resize' : ''; + }); + p.uplot.over.addEventListener('mouseleave', () => { + p.uplot.over.style.cursor = ''; + }); + + // Mousedown: drag an existing cursor (only when mode='on' and mouse is near a cursor line). + // If not near a cursor, the event falls through to uPlot for normal zoom/pan behavior. + p.uplot.over.addEventListener('mousedown', e => { + if (e.button !== 0 || e.shiftKey) return; // shift is pan + if (cursors.mode !== 'on') return; + const target = _cursorAtClientX(e.clientX); + if (!target) return; // not near a cursor — let uPlot handle zoom + + e.stopImmediatePropagation(); // prevent uPlot drag-zoom + e.preventDefault(); + + // Set cursor position immediately on mousedown + if (target === 'A') cursors.tA = _cursorValFromEvent(e); + else cursors.tB = _cursorValFromEvent(e); + updateCursorReadout(); + cursorsDirty = true; + + const onMove = ev => { + if (target === 'A') cursors.tA = _cursorValFromEvent(ev); + else cursors.tB = _cursorValFromEvent(ev); + updateCursorReadout(); + cursorsDirty = true; + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, true); // capture:true so we fire before uPlot's own handlers + + // ── Offset marker drag ───────────────────────────────────────────────────── + // Detect mousedown near the left edge of the plot area (marker triangle zone). + // Dragging moves the marker AND the signal together by changing screenPos. + p.div.addEventListener('mousedown', e => { + if (e.button !== 0 || !p.uplot || !p.uplot.bbox) return; + const canvas = p.uplot.ctx.canvas; + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const plotLeftCss = rect.left + p.uplot.bbox.left / dpr; + const markerZone = 12; // CSS px hit area to left/right of plot edge + + if (e.clientX > plotLeftCss + 3 || e.clientX < plotLeftCss - markerZone) return; + + // Find which marker was hit (closest to screenPos canvas position per signal). + let hitKey = null, hitDist = Infinity; + p.traces.forEach(key => { + const vs = sigVScale[p.id + ':' + key]; + const screenPos = vs ? (vs.screenPos || 0) : 0; + const yDev = p.uplot.valToPos(screenPos, 'y', true); + const yCss = rect.top + yDev / dpr; + const dist = Math.abs(e.clientY - yCss); + if (dist < 14 && dist < hitDist) { hitDist = dist; hitKey = key; } + }); + if (!hitKey) return; + + e.preventDefault(); + e.stopPropagation(); + setActiveSig(p.id, hitKey); + + const vs = getVScale(p.id, hitKey); + const startY = e.clientY; + const startScreenPos = vs.screenPos || 0; + const overRect = p.uplot.over.getBoundingClientRect(); + + const onMove = ev => { + const dy = ev.clientY - startY; // positive = down in canvas = lower y_norm + // Y scale spans 9 divisions over plot height; drag up → higher screenPos. + const dNorm = -dy / overRect.height * 9; + vs.screenPos = Math.max(-4, Math.min(4, startScreenPos + dNorm)); + // Keep "Position" input in sync if the vscale menu is open for this signal. + if (_vsMenuKey === hitKey) { + document.getElementById('vscale-pos').value = parseFloat(vs.screenPos.toPrecision(4)); + } + refreshPlotForKey(hitKey); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, true); + + // Pan support: Shift+left-drag pans the current view (synced across all plots). + // Works in both zoomed mode (xRange set) and rolling mode (freezes the window first). + let _panActive = false, _panAnchorX = 0, _panAnchorMin = 0, _panAnchorMax = 0; + p.uplot.over.addEventListener('mousedown', e => { + if (e.button !== 0 || !e.shiftKey) return; + e.stopImmediatePropagation(); + e.preventDefault(); + _panActive = true; + _panAnchorX = e.clientX; + const xr = p.xRange; + if (xr) { + _panAnchorMin = xr[0]; + _panAnchorMax = xr[1]; + } else { + // Rolling mode: capture the current window position and freeze it so we + // have a stable anchor to pan from. + let now = -Infinity; + p.traces.forEach(key => { + const buf = buffers[key]; + if (buf && buf.size > 0) { + const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + if (t > now) now = t; + } + }); + if (!isFinite(now)) now = Date.now() / 1000; + _panAnchorMin = now - windowSec; + _panAnchorMax = now; + // Freeze all plots at this position immediately. + const pMin = _panAnchorMin, pMax = _panAnchorMax; + zoomGuard = true; + plots.forEach(q => { + q.xRange = [pMin, pMax]; + if (q.uplot) q.uplot.setScale('x', { min: pMin, max: pMax }); + }); + zoomGuard = false; + if (!syncLocked) { + syncLocked = true; + const btnR = document.getElementById('btn-sync-resume'); + if (btnR) btnR.style.display = ''; + } + } + }, true); + const _onPanMove = e => { + if (!_panActive || !p.uplot) return; + const w = p.uplot.over.getBoundingClientRect().width; + const span = _panAnchorMax - _panAnchorMin; + const dt = -((e.clientX - _panAnchorX) / w) * span; + const newMin = _panAnchorMin + dt; + const newMax = _panAnchorMax + dt; + zoomGuard = true; + plots.forEach(q => { + q.xRange = [newMin, newMax]; + if (q.uplot) q.uplot.setScale('x', { min: newMin, max: newMax }); + q.needsRedraw = true; + }); + zoomGuard = false; + }; + const _onPanEnd = () => { _panActive = false; }; + document.addEventListener('mousemove', _onPanMove); + document.addEventListener('mouseup', _onPanEnd); + + // Resize observer so the uPlot fills its container. + // zoomGuard prevents setSize → setScale → hook from calling onZoom. + if (p.ro) { p.ro.disconnect(); } + p.ro = new ResizeObserver(() => { + if (!p.uplot) return; + const w = Math.max(p.div.clientWidth || 50, 50); + const h = Math.max(p.div.clientHeight || 50, 50); + zoomGuard = true; + p.uplot.setSize({ width: w, height: h }); + zoomGuard = false; + }); + p.ro.observe(p.div); +} + +// Build the uPlot data array from buffers / trigger snapshot +function buildUPlotData(p, inTrigMode) { + if (p.traces.length === 0) return [new Float64Array(0)]; + + // When trigger is enabled but no snapshot yet (armed/waiting), return + // the last-rendered data so the plot stays frozen. + if (trig.enabled && !inTrigMode) { + if (!p.uplot || !p.uplot.data || !p.uplot.data[0]) return [new Float64Array(0)]; + return p.uplot.data; + } + + if (inTrigMode && trig.snapshot) return buildTrigData(p); + return buildLiveData(p); +} + +// Resample (vSrc) from times (tSrc) onto target times (tDst) using linear interpolation. +// tSrc must be sorted ascending. Values outside tSrc range are clamped to the nearest +// endpoint (extrapolation is not safe for streaming data). +function resampleLinear(tSrc, vSrc, tDst) { + const n = tDst.length; + const out = new Float64Array(n); + if (tSrc.length === 0) return out; // all zeros + if (tSrc.length === 1) { out.fill(vSrc[0]); return out; } + let j = 0; + for (let i = 0; i < n; i++) { + const td = tDst[i]; + // Advance j so that tSrc[j] <= td < tSrc[j+1] (or j at last index) + while (j < tSrc.length - 2 && tSrc[j + 1] < td) j++; + if (td <= tSrc[0]) { + out[i] = vSrc[0]; + } else if (td >= tSrc[tSrc.length - 1]) { + out[i] = vSrc[vSrc.length - 1]; + } else { + const t0 = tSrc[j], t1 = tSrc[j + 1]; + const frac = (td - t0) / (t1 - t0); + out[i] = vSrc[j] + frac * (vSrc[j + 1] - vSrc[j]); + } + } + return out; +} + +function buildLiveData(p) { + if (p.traces.length === 0) return [new Float64Array(0)]; + + const plotNow = computePlotNow(p); + const t0 = p.xRange ? p.xRange[0] : plotNow - windowSec; + const t1 = p.xRange ? p.xRange[1] : plotNow; + + const isRolling = !p.xRange; + + // When zoomed, prefer server-fetched hi-res data if it covers this exact range. + if (p.xRange) { + const zd = zoomData[p.id]; + if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) { + const fetched = buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2)); + // Only use server data if it actually has samples; otherwise fall through to local buffer. + if (fetched[0] && fetched[0].length > 0) return fetched; + } + } + + // Slice all traces; pick master by sampling rate then count. + // In zoom mode, include one bracketing point on each side so lines are drawn + // across the visible area even when the window contains 0 or 1 samples. + const sliceFn = isRolling ? getBufferSliceRange : getBufferSliceRangeWithBrackets; + const slices = {}; + let masterKey = p.traces[0], masterCount = -1, masterRate = -1; + for (const key of p.traces) { + const buf = buffers[key]; + if (!buf || buf.size === 0) continue; + const sl = sliceFn(buf, t0, t1); + slices[key] = sl; + const rate = getKeySamplingRate(key); + if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) { + masterRate = rate; masterCount = sl.t.length; masterKey = key; + } + } + + const masterRaw = slices[masterKey]; + if (!masterRaw || masterRaw.t.length === 0) + return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; + + // Decimate to pixel-adaptive point count in both rolling and zoomed modes. + // The server sends ≤2000 pts/tick but the buffer accumulates many ticks, so + // the full window slice can easily reach 100k–300k pts — far more than uPlot + // needs for a 1200px-wide canvas. Always run LTTB via the background worker + // (stale-while-revalidate: use cached result; fall back to sync on first render). + // Rolling-mode cache key uses _dataGen so the result refreshes on new data. + const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); + const cacheKey = isRolling + ? `${p.id}:${masterKey}:rolling:${_dataGen}` + : `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`; + let sharedT, masterV; + if (masterRaw.t.length <= targetPts) { + // Data already sparse enough — use directly (no decimation needed). + sharedT = masterRaw.t; + masterV = masterRaw.v; + } else { + const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts); + let dec; + if (cached) { + dec = cached; + } else { + // Worker job submitted — sync fallback this frame so the plot isn't blank. + dec = lttb(masterRaw.t, masterRaw.v, targetPts); + } + sharedT = dec.t; + masterV = dec.v; + } + + const yArrays = []; + for (const key of p.traces) { + if (key === masterKey) { yArrays.push(masterV); continue; } + const sl = slices[key]; + if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; } + yArrays.push(resampleLinear(sl.t, sl.v, sharedT)); + } + + return [sharedT, ...applyVScaleNorm(p, yArrays)]; +} + +function buildTrigData(p) { + const trigT = trig.trigTime; + const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); + const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec(); + + if (p.traces.length === 0) return [new Float64Array(0)]; + + const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS; + const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS; + + const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); + + // Slice all traces; pick master by samplingRate first, then sample count + const slices = {}; + let masterKey = p.traces[0], masterCount = -1, masterRate = -1; + for (const key of p.traces) { + const snap = trig.snapshot[key]; + if (!snap) continue; + const sl = sliceTypedArrayRange(snap.t, snap.v, t0, t1); + slices[key] = sl; + const rate = getKeySamplingRate(key); + if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) { + masterRate = rate; masterCount = sl.t.length; masterKey = key; + } + } + + const masterRaw = slices[masterKey]; + if (!masterRaw || masterRaw.t.length === 0) + return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; + + // Trig snapshot is fixed (no new data arrives after capture), so the cache + // key only needs range + data length. Worker result persists until re-arm. + const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`; + const cachedDec = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts); + const dec = cachedDec || lttb(masterRaw.t, masterRaw.v, targetPts); + // Convert absolute → relative seconds + const sharedT = new Float64Array(dec.t.length); + for (let i = 0; i < dec.t.length; i++) sharedT[i] = dec.t[i] - trigT; + + const yArrays = []; + for (const key of p.traces) { + if (key === masterKey) { yArrays.push(dec.v); continue; } + const sl = slices[key]; + if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; } + const relT = new Float64Array(sl.t.length); + for (let i = 0; i < sl.t.length; i++) relT[i] = sl.t[i] - trigT; + yArrays.push(resampleLinear(relT, sl.v, sharedT)); + } + + return [sharedT, ...applyVScaleNorm(p, yArrays)]; +} + +/* ════════════════════════════════════════════════════════════════ + Zoom sync + ════════════════════════════════════════════════════════════════ */ +let syncLocked = false; + +function onZoom(sourcePlotId, min, max) { + // Push current range to history before applying new zoom + const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null; + zoomHistory.push(prevRange); + if (zoomHistory.length > 30) zoomHistory.shift(); + document.getElementById('btn-zoom-back').style.display = ''; + + // Store zoom on source plot + const src = plots.find(p => p.id === sourcePlotId); + if (src) src.xRange = [min, max]; + + // Show Auto button in live mode + if (!trig.enabled && !syncLocked) { + syncLocked = true; + document.getElementById('btn-sync-resume').style.display = ''; + } + + // Propagate to other plots + zoomGuard = true; + plots.forEach(p => { + if (p.id === sourcePlotId) return; + p.xRange = [min, max]; + if (p.uplot) p.uplot.setScale('x', { min, max }); + }); + zoomGuard = false; + + // Evict stale LTTB cache entries — new zoom range needs fresh decimation. + plots.forEach(p => lttbCacheEvict(p.id)); + + // Mark all plots dirty (re-slice data to the new range for full resolution) + plots.forEach(p => { p.needsRedraw = true; }); + + // Schedule hi-res fetch from ring buffers. + scheduleZoomFetch(min, max); +} + +// Undo last zoom/pan action +function zoomBack() { + if (!zoomHistory.length) return; + const prev = zoomHistory.pop(); + if (!zoomHistory.length) document.getElementById('btn-zoom-back').style.display = 'none'; + + // Discard stale zoom data regardless of direction. + Object.keys(zoomData).forEach(k => delete zoomData[k]); + cancelZoomFetch(); + + if (prev === null) { + // Was at auto/rolling state before the zoom + resetZoom(); + } else { + zoomGuard = true; + plots.forEach(p => { + p.xRange = [...prev]; + if (p.uplot) p.uplot.setScale('x', { min: prev[0], max: prev[1] }); + p.needsRedraw = true; + }); + zoomGuard = false; + scheduleZoomFetch(prev[0], prev[1]); + } +} + +// Reset to auto/rolling window (clears all zoom) +function resetZoom() { + Object.keys(zoomData).forEach(k => delete zoomData[k]); + cancelZoomFetch(); + syncLocked = false; + document.getElementById('btn-sync-resume').style.display = 'none'; + if (trig.enabled && trig.snapshot) { + const preS = trig.snapshot._preS || trigPreSec(); + const postS = trig.snapshot._postS || trigPostSec(); + zoomGuard = true; + plots.forEach(p => { + p.xRange = null; + if (p.uplot) p.uplot.setScale('x', { min: -preS, max: postS }); + p.needsRedraw = true; + }); + zoomGuard = false; + } else { + // Back to rolling window — setScale to current window, render loop keeps it moving + plots.forEach(p => { + p.xRange = null; + if (!globalPause) p.needsRedraw = true; + }); + } +} + +// Fit x-axis to all data currently in buffers (or full trigger snapshot) +function zoomFit() { + if (trig.enabled && trig.snapshot) { + resetZoom(); // "Fit" in trigger mode = show full trigger window + return; + } + // Find oldest/newest timestamps across all visible signals + let gMin = Infinity, gMax = -Infinity; + plots.forEach(p => { + p.traces.forEach(key => { + const buf = buffers[key]; if (!buf || buf.size === 0) return; + const startIdx = (buf.size === buf.cap) ? buf.head : 0; + const oldestT = buf.t[startIdx]; + const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + if (oldestT < gMin) gMin = oldestT; + if (newestT > gMax) gMax = newestT; + }); + }); + if (!isFinite(gMin) || gMin >= gMax) return; + + // Push to history + const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null; + zoomHistory.push(prevRange); + document.getElementById('btn-zoom-back').style.display = ''; + if (!syncLocked) { syncLocked = true; document.getElementById('btn-sync-resume').style.display = ''; } + + zoomGuard = true; + plots.forEach(p => { + p.xRange = [gMin, gMax]; + if (p.uplot) p.uplot.setScale('x', { min: gMin, max: gMax }); + p.needsRedraw = true; + }); + zoomGuard = false; + scheduleZoomFetch(gMin, gMax); +} + +// Auto = return to rolling window / full trigger window +function exitSyncLock() { resetZoom(); } + +document.getElementById('btn-sync-resume').addEventListener('click', resetZoom); +document.getElementById('btn-zoom-back').addEventListener('click', zoomBack); +document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit); + +/* ════════════════════════════════════════════════════════════════ + Cursor controls + ════════════════════════════════════════════════════════════════ */ +// Show the cursor button only when paused or in trigger-snapshot mode. +function updateCursorBtnVisibility() { + const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null); + const btn = document.getElementById('btn-cursor'); + btn.style.display = canUseCursors ? '' : 'none'; + if (!canUseCursors && cursors.mode !== 'off') { + cursors.mode = 'off'; + cursors.tA = null; cursors.tB = null; // context changed, clear positions + btn.textContent = 'Cursors'; + btn.classList.remove('active'); + document.getElementById('cursor-readout').classList.remove('visible'); + cursorsDirty = true; + } +} + +document.getElementById('btn-cursor').addEventListener('click', () => { + cursors.mode = cursors.mode === 'off' ? 'on' : 'off'; + const btn = document.getElementById('btn-cursor'); + btn.textContent = 'Cursors'; + btn.classList.toggle('active', cursors.mode === 'on'); + if (cursors.mode === 'on') { + // Auto-place at 25%/75% of current x range on first use + if (cursors.tA === null && cursors.tB === null) { + const refPlot = plots.find(p => p.uplot); + if (refPlot) { + const { min, max } = refPlot.uplot.scales.x; + const span = max - min; + cursors.tA = min + span * 0.25; + cursors.tB = min + span * 0.75; + } + } + updateCursorReadout(); + document.getElementById('cursor-readout').classList.add('visible'); + } else { + document.getElementById('cursor-readout').classList.remove('visible'); + } + cursorsDirty = true; +}); + +// Format a signal value for the per-plot cursor readout. +function fmtVal(v) { + if (v === null || v === undefined) return '—'; + return Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString(); +} + +// Interpolate the real-unit value of the active/sole signal in plot p at time t. +function getValueAtCursor(p, t) { + if (!p.uplot || t === null) return null; + const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null); + if (!key) return null; + const idx = p.traces.indexOf(key); + if (idx < 0) return null; + const vNorm = interpAtTime(p.uplot, idx + 1, t); + if (vNorm === null) return null; + // Un-normalize: y_norm = (y_raw - offset) / divValue + screenPos + const vs = sigVScale[p.id + ':' + key]; + if (!vs) return vNorm; + const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1); + const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); + const sp = vs.screenPos || 0; + return (vNorm - sp) * dv + ofs; +} + +// Update per-plot cursor value readouts (A, B, ΔV) for all plots. +function updatePlotCursorReadouts() { + plots.forEach(p => { + const el = document.getElementById('pcur-' + p.id); + if (!el) return; + // Show only when cursors are on and the plot has an active or sole signal. + const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null); + if (cursors.mode !== 'on' || !key || !p.uplot) { + el.style.display = 'none'; + return; + } + const vA = getValueAtCursor(p, cursors.tA); + const vB = getValueAtCursor(p, cursors.tB); + const dv = (vA !== null && vB !== null) ? vB - vA : null; + document.getElementById('pcur-a-' + p.id).textContent = 'A: ' + fmtVal(vA); + document.getElementById('pcur-b-' + p.id).textContent = 'B: ' + fmtVal(vB); + document.getElementById('pcur-dv-' + p.id).textContent = 'ΔV: ' + fmtVal(dv); + el.style.display = 'flex'; + }); +} + +function updateCursorReadout() { + const ro = document.getElementById('cursor-readout'); + const active = cursors.mode === 'on'; + ro.classList.toggle('visible', active); + updatePlotCursorReadouts(); + if (!active) return; + + // Use the current visible x-range to pick the display unit. + const span = currentXSpan(); + + // Format a cursor position: trigger mode → signed relative duration; + // live mode → absolute wall time with span-appropriate precision. + const fmt = v => { + if (v === null) return '—'; + if (trig.enabled && trig.snapshot) return fmtDuration(v, span, true); + return fmtLiveTime(v, span); + }; + + document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA); + document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB); + + if (cursors.tA !== null && cursors.tB !== null) { + const dt = cursors.tB - cursors.tA; + // ΔT auto-scales by its own magnitude for precision regardless of x-range. + document.getElementById('cur-dt').textContent = 'ΔT: ' + fmtDuration(dt, Math.abs(dt), true); + } else { + document.getElementById('cur-dt').textContent = 'ΔT: —'; + } +} + +/* ════════════════════════════════════════════════════════════════ + Trigger bar controls + ════════════════════════════════════════════════════════════════ */ +function openTrigBar(open) { + trig.enabled = open; + document.getElementById('trigbar').classList.toggle('open', open); + document.getElementById('btn-trigger').classList.toggle('trig-active', open); + document.documentElement.style.setProperty('--trigbar-h', open ? '48px' : '0px'); + // Streaming-only controls are irrelevant while the trigger is active + const none = open ? 'none' : ''; + document.getElementById('btn-pause-global').style.display = none; + document.getElementById('window-select').style.display = none; + document.getElementById('lbl-window').style.display = none; + if (!open) { + trigDisarm(); trig.snapshot = null; + cursors.tA = null; cursors.tB = null; updateCursorReadout(); + updateCursorBtnVisibility(); + zoomHistory.length = 0; + document.getElementById('btn-zoom-back').style.display = 'none'; + plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); + } else { + cursors.tA = null; cursors.tB = null; updateCursorReadout(); + updateCursorBtnVisibility(); + zoomHistory.length = 0; + document.getElementById('btn-zoom-back').style.display = 'none'; + resetZoom(); + if (trig.signal) trigArm(); else updateTrigStatusBadge('idle'); + } + // Resize uPlot instances after trigbar height changes + setTimeout(() => { + plots.forEach(p => { + if (!p.uplot) return; + p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); + }); + }, 220); +} +document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled)); +document.getElementById('trig-signal').addEventListener('change', e => { + const val = e.target.value; + if (!val) { trig.signal = ''; trigDisarm(); return; } + // All signals (including non-temporal arrays) now use the base key. + // Array signals get a snapshot waveform buffer; the trigger fires on + // the last-pushed element value. + trig.signal = val; trig.prevVal = null; + if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm(); +}); +document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; }); +document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; if (trig.armed) trig.prevVal = null; }); +document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); }); +document.getElementById('trig-pre').addEventListener('input', e => { + trig.prePercent = parseInt(e.target.value, 10); + document.getElementById('trig-pre-val').textContent = trig.prePercent + '%'; +}); +document.getElementById('trig-mode').addEventListener('change', e => { + trig.mode = e.target.value; + // If a snapshot already exists the trigger has already fired — update button + // visibility immediately so it matches the newly selected mode. + if (trig.snapshot) { + showRearmBtn(trig.mode === 'single'); + showStopBtn(trig.mode === 'normal'); + updateStopBtn(); + } +}); +document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); }); +document.getElementById('btn-trig-stop').addEventListener('click', () => { + if (!trig.enabled || trig.mode !== 'normal') return; + trig.stopped = !trig.stopped; + updateStopBtn(); + if (!trig.stopped) trigArm(); // resume: re-arm immediately +}); + +/* ════════════════════════════════════════════════════════════════ + Trigger signal selector + ════════════════════════════════════════════════════════════════ */ +function buildTrigSignalSelect() { + const sel = document.getElementById('trig-signal'); + // Preserve the currently active trig.signal (may include an array index like "[3]"). + const curBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : ''; + sel.innerHTML = ''; + Object.values(sourcesMap).forEach(src => { + const prefix = src.id + ':'; + const srcLabel = src.label || src.addr || src.id; + (src.signals || []).forEach(sig => { + const n = numElements(sig); + const key = prefix + sig.name; + const o = document.createElement('option'); + o.value = key; + o.textContent = srcLabel + ': ' + sig.name + (n > 1 ? ' [0…' + (n - 1) + ']' : ''); + sel.appendChild(o); + }); + }); + if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase; +} + +/* ════════════════════════════════════════════════════════════════ + Sidebar + ════════════════════════════════════════════════════════════════ */ +const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64']; + +function buildSidebar() { + const list = document.getElementById('signal-list'); + list.innerHTML = ''; + const sources = Object.values(sourcesMap); + + sources.forEach(src => { + const sigs = src.signals || []; + const prefix = src.id + ':'; + + // Source header + const grp = document.createElement('div'); + grp.className = 'source-group'; + + const hdr = document.createElement('div'); + hdr.className = 'source-group-header'; + + const dot = document.createElement('span'); + dot.className = 'source-state-dot ' + (src.state || 'disconnected'); + + const nameEl = document.createElement('span'); + nameEl.className = 'source-name'; + nameEl.textContent = src.label || src.addr || src.id; + nameEl.title = src.addr || ''; + + const addrEl = document.createElement('span'); + addrEl.className = 'source-addr'; + if (src.label && src.addr) addrEl.textContent = src.addr; + + const rmBtn = document.createElement('button'); + rmBtn.className = 'source-remove-btn'; + rmBtn.title = 'Remove source'; + rmBtn.textContent = '×'; + rmBtn.addEventListener('click', () => removeSource(src.id)); + + hdr.append(dot, nameEl, addrEl, rmBtn); + grp.appendChild(hdr); + + sigs.forEach(sig => { + const n = numElements(sig); + const typeName = _typeNames[sig.typeCode] || '?'; + const globalKey = prefix + sig.name; + if (n === 1) { + grp.appendChild(makeDraggable(globalKey, sig.name, typeName, sig.unit || '')); + } else { + // Both temporal and snapshot-waveform (non-temporal) arrays are dragged + // as a whole signal (base key). Temporal: plot vs time; snapshot: plot vs element index. + grp.appendChild(makeDraggable(globalKey, sig.name, '[' + n + '] ' + typeName, sig.unit || '')); + } + }); + + list.appendChild(grp); + }); + + if (!sources.length) { + const empty = document.createElement('div'); + empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;'; + empty.textContent = 'No sources configured'; + list.appendChild(empty); + } + + list.appendChild(makeAddSourceSection()); +} +function makeDraggable(key, label, typeName, unit) { + const item = document.createElement('div'); + item.className = 'sig-item'; item.draggable = true; + item.innerHTML = '' + escHtml(label) + '' + + (unit ? '' + escHtml(unit) + '' : '') + + '' + escHtml(typeName) + ''; + item.addEventListener('dragstart', e => { + e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy'; + requestAnimationFrame(() => item.classList.add('dragging')); + }); + item.addEventListener('dragend', () => item.classList.remove('dragging')); + return item; +} + +/* ════════════════════════════════════════════════════════════════ + Grid resize handles + ════════════════════════════════════════════════════════════════ */ +function updateGridTemplate() { + const grid = document.getElementById('plot-grid'); + grid.style.gridTemplateColumns = colFrs.map(f => f + 'fr').join(' '); + grid.style.gridTemplateRows = rowFrs.map(f => f + 'fr').join(' '); + // Reposition all handles + document.querySelectorAll('.resize-handle-v').forEach(h => { + const i = parseInt(h.dataset.col); + const tot = colFrs.reduce((a, b) => a + b, 0); + h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; + }); + document.querySelectorAll('.resize-handle-h').forEach(h => { + const i = parseInt(h.dataset.row); + const tot = rowFrs.reduce((a, b) => a + b, 0); + h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; + }); + // Notify uPlot of new sizes + plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); }); +} + +function setupResizeHandles(cols, rows) { + const grid = document.getElementById('plot-grid'); + grid.querySelectorAll('.resize-handle-v, .resize-handle-h').forEach(el => el.remove()); + const makeHandle = (cls, dataset, onDown) => { + const h = document.createElement('div'); + h.className = cls; + Object.assign(h.dataset, dataset); + h.addEventListener('mousedown', onDown); + grid.appendChild(h); + return h; + }; + for (let i = 0; i < cols - 1; i++) { + const h = makeHandle('resize-handle-v', { col: i }, e => { + e.preventDefault(); + const rect = grid.getBoundingClientRect(); + const tot = colFrs.reduce((a, b) => a + b, 0); + const pairSum = colFrs[i] + colFrs[i + 1]; + const leftBefore = colFrs.slice(0, i).reduce((a, b) => a + b, 0); + h.classList.add('dragging'); + const onMove = ev => { + const pct = (ev.clientX - rect.left) / rect.width; + const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - leftBefore)); + colFrs[i] = newI; colFrs[i + 1] = pairSum - newI; + updateGridTemplate(); + }; + const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + const tot = colFrs.reduce((a, b) => a + b, 0); + h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; + } + for (let i = 0; i < rows - 1; i++) { + const h = makeHandle('resize-handle-h', { row: i }, e => { + e.preventDefault(); + const rect = grid.getBoundingClientRect(); + const tot = rowFrs.reduce((a, b) => a + b, 0); + const pairSum = rowFrs[i] + rowFrs[i + 1]; + const topBefore = rowFrs.slice(0, i).reduce((a, b) => a + b, 0); + h.classList.add('dragging'); + const onMove = ev => { + const pct = (ev.clientY - rect.top) / rect.height; + const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - topBefore)); + rowFrs[i] = newI; rowFrs[i + 1] = pairSum - newI; + updateGridTemplate(); + }; + const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + const tot = rowFrs.reduce((a, b) => a + b, 0); + h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; + } +} + +/* ════════════════════════════════════════════════════════════════ + Plot config toolbar (click plot title to open) + ════════════════════════════════════════════════════════════════ */ +let _pcfgOpenId = null; + +function showPlotCfg(plotId) { + if (_pcfgOpenId != null && _pcfgOpenId !== plotId) hidePlotCfg(); + _pcfgOpenId = plotId; + document.getElementById('pcfg-' + plotId).style.display = 'block'; +} + +function hidePlotCfg() { + if (_pcfgOpenId == null) return; + const bar = document.getElementById('pcfg-' + _pcfgOpenId); + if (bar) bar.style.display = 'none'; + _pcfgOpenId = null; +} + +function initPlotCfgBar(plotId, p) { + const bar = document.getElementById('pcfg-' + plotId); + if (!bar) return; + + const titleInput = bar.querySelector('.pcfg-title-input'); + const titleEl = document.getElementById('ptitle-' + plotId); + + titleInput.addEventListener('input', () => { + p.title = titleInput.value || ('Plot ' + plotId); + if (titleEl) titleEl.textContent = p.title; + }); + + bar.querySelectorAll('.pcfg-mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + const newMode = btn.dataset.mode; + if (p.mode === newMode) return; + p.mode = newMode; + bar.querySelectorAll('.pcfg-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === newMode)); + createUPlot(p); + p.needsRedraw = true; + }); + }); + + bar.querySelector('.pcfg-close-btn').addEventListener('click', hidePlotCfg); +} + +/* ════════════════════════════════════════════════════════════════ + Layout management + ════════════════════════════════════════════════════════════════ */ +// Returns the number of plot cells in a layout (cols × rows). +function layoutPlotCount(cls) { + const m = cls.match(/^l(\d+)x(\d+)$/); + return m ? parseInt(m[1]) * parseInt(m[2]) : 1; +} + +// Build a small SVG grid thumbnail for a given cols×rows layout. +function layoutSVG(cols, rows) { + const W = 28, H = 20, GAP = 1.5, PAD = 1.5; + const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols; + const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows; + let rects = ''; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = (PAD + c * (cw + GAP)).toFixed(1); + const y = (PAD + r * (ch + GAP)).toFixed(1); + rects += ``; + } + } + return `` + + `` + + `${rects}`; +} + +// Apply a new layout: switch the grid class and auto-add/remove plots. +// Plots with traces are preserved; empty plots are removed first when shrinking. +function applyLayout(cls) { + const entry = LAYOUTS.find(l => l[1] === cls); + if (!entry) return; + const [label, , cols, rows] = entry; + currentLayout = cls; + + // Reset fr sizes when layout dimensions change; keep sizes when same layout is re-applied. + if (cols !== _gridCols || rows !== _gridRows) { + colFrs = Array(cols).fill(1); + rowFrs = Array(rows).fill(1); + _gridCols = cols; _gridRows = rows; + } + + const grid = document.getElementById('plot-grid'); + grid.className = cls; // keeps other grid CSS rules + updateGridTemplate(); // override template with current fr sizes + setupResizeHandles(cols, rows); + + // Update button label + const btn = document.getElementById('btn-layout'); + if (btn) btn.innerHTML = layoutSVG(cols, rows) + ' ' + label + ' ▾'; + + // Update active state in menu + document.querySelectorAll('.layout-menu-item') + .forEach(el => el.classList.toggle('active', el.dataset.layout === cls)); + + const needed = layoutPlotCount(cls); + + // Remove excess plots — prefer empty ones to preserve trace assignments. + while (plots.length > needed) { + let removeId = plots[plots.length - 1].id; + for (let i = plots.length - 1; i >= 0; i--) { + if (plots[i].traces.length === 0) { removeId = plots[i].id; break; } + } + deletePlot(removeId); + } + + // Add missing plots (DOM only — uPlot created below after layout settles). + while (plots.length < needed) addPlot(); + + // Recreate all uPlot instances once the CSS grid has sized the cells. + requestAnimationFrame(() => { + plots.forEach(p => { + createUPlot(p); + p.needsRedraw = true; + }); + setTimeout(() => plots.forEach(p => { + if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); + }), 60); + }); +} + +function buildLayoutMenu() { + const menu = document.getElementById('layout-menu'); + + LAYOUTS.forEach(([label, cls, cols, rows]) => { + const item = document.createElement('button'); + item.className = 'layout-menu-item' + (cls === currentLayout ? ' active' : ''); + item.dataset.layout = cls; + item.innerHTML = layoutSVG(cols, rows) + '' + label + ''; + item.addEventListener('click', () => { + applyLayout(cls); + menu.classList.remove('open'); + }); + menu.appendChild(item); + }); + + // Toggle menu open/close + document.getElementById('btn-layout').addEventListener('click', e => { + e.stopPropagation(); + const r = e.currentTarget.getBoundingClientRect(); + menu.style.left = r.left + 'px'; + menu.style.top = (r.bottom + 4) + 'px'; + menu.classList.toggle('open'); + }); + + // Close when clicking outside + document.addEventListener('click', e => { + if (!e.target.closest('#layout-menu') && !e.target.closest('#btn-layout')) + menu.classList.remove('open'); + }); +} + +/* ════════════════════════════════════════════════════════════════ + Export CSV (all plots) — fetches full-resolution data from ring + ════════════════════════════════════════════════════════════════ */ +async function exportAllCSV() { + const btn = document.getElementById('btn-csv-all'); + if (btn.disabled) return; + + const inTrigMode = trig.enabled && trig.snapshot !== null; + + // Collect unique signal keys across all plots (preserving order). + const keys = []; + plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); })); + if (!keys.length) return; + + // Determine time range to export. + let t0, t1, relOffset = 0; + if (inTrigMode) { + // Export the full trigger window around the trigger event. + t0 = trig.trigTime - trigPreSec(); + t1 = trig.trigTime + trigPostSec(); + relOffset = trig.trigTime; + } else { + // Use the current zoom range if active, else the rolling window. + const refPlot = plots.find(p => p.xRange); + if (refPlot) { + [t0, t1] = refPlot.xRange; + } else { + let plotNow = -Infinity; + keys.forEach(k => { + const buf = buffers[k]; + if (buf && buf.size > 0) { + const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + if (t > plotNow) plotNow = t; + } + }); + if (!isFinite(plotNow)) plotNow = Date.now() / 1000; + t0 = plotNow - windowSec; + t1 = plotNow; + } + } + + // Show loading state. + const origLabel = btn.textContent; + btn.textContent = '⏳ Downloading…'; + btn.disabled = true; + + // Fetch full-resolution ring data (n=0 → no LTTB decimation). + const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`; + let ringSignals = null; + try { + const resp = await fetch(url); + if (resp.ok) { + const json = await resp.json(); + ringSignals = json && json.signals; + } + } catch (e) { + console.warn('CSV export: ring fetch failed, falling back to push buffer', e); + } finally { + btn.textContent = origLabel; + btn.disabled = false; + } + + // Build per-signal time/value arrays. + // Priority: ring buffer (full res) → trigger snapshot → push buffer. + const slices = keys.map(key => { + const rd = ringSignals && ringSignals[key]; + if (rd && rd.t && rd.t.length > 0) { + const t = rd.t, v = rd.v; + if (inTrigMode) { + return { t: Array.from(t).map(ts => ts - relOffset), v: Array.from(v) }; + } + return { t: Array.from(t), v: Array.from(v) }; + } + // Fallback: push buffer or trigger snapshot. + if (inTrigMode) { + const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) }; + return { t: Array.from(raw.t).map(ts => ts - relOffset), v: Array.from(raw.v) }; + } + const buf = buffers[key]; if (!buf) return { t: [], v: [] }; + const sl = getBufferSliceRange(buf, t0, t1); + return { t: Array.from(sl.t), v: Array.from(sl.v) }; + }); + + // Merge all timestamps and build aligned rows. + const allT = new Set(); + slices.forEach(s => s.t.forEach(t => allT.add(t))); + const sortedT = Array.from(allT).sort((a, b) => a - b); + if (!sortedT.length) return; + + const lookups = slices.map(s => { + const m = new Map(); + s.t.forEach((t, i) => m.set(t, s.v[i])); + return m; + }); + + // Strip "sourceId:" prefix from column headers for readability. + const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k)); + const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(','); + const rows = sortedT.map(t => + [t.toFixed(9), ...lookups.map(lk => (lk.has(t) ? lk.get(t) : ''))].join(',') + ); + const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'signals_' + Date.now() + '.csv'; + a.click(); + URL.revokeObjectURL(a.href); +} + +/* ════════════════════════════════════════════════════════════════ + Plot management + ════════════════════════════════════════════════════════════════ */ +function addPlot() { + const id = nextPlotId++; + const card = document.createElement('div'); + card.className = 'plot-card'; card.dataset.plotId = id; + card.innerHTML = ` +
+ Plot ${id} +
+ +
+ +
+
+
Drop signals here
+
⚡ Collecting…
+
`; + + card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; card.classList.add('drag-over'); }); + card.addEventListener('dragleave', () => card.classList.remove('drag-over')); + card.addEventListener('drop', e => { + e.preventDefault(); card.classList.remove('drag-over'); + const key = e.dataTransfer.getData('signal'); if (key) addTraceTo(id, key); + }); + + document.getElementById('plot-grid').appendChild(card); + + const plotBody = card.querySelector('#pbody-' + id); + const p = { id, title: 'Plot ' + id, mode: 'normal', traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 }; + plots.push(p); + + // Wire config toolbar + initPlotCfgBar(id, p); + card.querySelector('#ptitle-' + id).addEventListener('click', () => { + if (_pcfgOpenId === id) hidePlotCfg(); else showPlotCfg(id); + }); + + // uPlot creation is handled by applyLayout (batch, after DOM settles). + return id; +} + +function addTraceTo(plotId, signalKey) { + const p = plots.find(p => p.id === plotId); if (!p) return; + if (p.traces.includes(signalKey)) return; + p.traces.push(signalKey); + document.querySelector('#hint-' + plotId).style.display = 'none'; + addBadge(plotId, signalKey); + // Recreate uPlot with new series list + createUPlot(p); + p.needsRedraw = true; + updatePlotCursorReadouts(); +} + +function removeTraceFrom(plotId, signalKey) { + const p = plots.find(p => p.id === plotId); if (!p) return; + p.traces = p.traces.filter(t => t !== signalKey); + removeBadge(plotId, signalKey); + // If the removed trace was active, close toolbar and pick a new active signal. + if (plotActiveSignal[plotId] === signalKey) { + if (_vsMenuPlotId === plotId) hideVScaleMenu(); + const newActive = p.traces[0] || null; + if (newActive) setActiveSig(plotId, newActive); else delete plotActiveSignal[plotId]; + } + createUPlot(p); + p.needsRedraw = true; + if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = ''; + updatePlotCursorReadouts(); +} + +function addBadge(plotId, key) { + const c = document.getElementById('badges-' + plotId); if (!c) return; + if (c.querySelector('[data-key="' + CSS.escape(key) + '"]')) return; + const color = getSigStyle(key).color; + const badge = document.createElement('span'); + badge.className = 'sig-badge'; badge.dataset.key = key; + + const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color; + + // Show signal name without the "sourceId:" prefix. + const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const nameSpan = document.createElement('span'); nameSpan.textContent = displayName; + + // Small vscale info text (V/div + offset when not in auto mode). + const infoSpan = document.createElement('span'); infoSpan.className = 'vscale-info'; + + const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×'; + x.addEventListener('click', e => { e.stopPropagation(); removeTraceFrom(plotId, key); }); + + // Left-click: select + show vscale toolbar; click same signal again to deselect. + badge.addEventListener('click', e => { + if (e.target === x) return; + const isActive = plotActiveSignal[plotId] === key; + const toolbarVisible = _vsMenuKey === key && _vsMenuPlotId === plotId; + if (isActive && toolbarVisible) { + setActiveSig(plotId, null); + hideVScaleMenu(); + } else { + setActiveSig(plotId, key); + showVScaleMenu(key, plotId); + } + }); + // Right-click: open signal style (color/width/…) menu. + badge.addEventListener('contextmenu', e => { + e.preventDefault(); + showSignalMenu(key, plotId, e.clientX, e.clientY); + }); + + badge.appendChild(dot); + badge.appendChild(nameSpan); + badge.appendChild(infoSpan); + badge.appendChild(x); + c.appendChild(badge); + + // Auto-activate the first signal added to this plot. + if (!plotActiveSignal[plotId]) setActiveSig(plotId, key); +} +function removeBadge(plotId, key) { + const c = document.getElementById('badges-' + plotId); if (!c) return; + const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (b) b.remove(); +} + + +function deletePlot(plotId) { + const idx = plots.findIndex(p => p.id === plotId); if (idx === -1) return; + const p = plots[idx]; + if (p.ro) p.ro.disconnect(); + if (p.uplot) p.uplot.destroy(); + lttbCacheEvict(plotId); + plots.splice(idx, 1); + document.querySelector('[data-plot-id="' + plotId + '"]').remove(); +} + +/* ════════════════════════════════════════════════════════════════ + Render loop + ════════════════════════════════════════════════════════════════ */ +let _dbgTick = 0; +let _dataGen = 0; // incremented each time new data arrives +function renderDirtyPlots() { + // Compute global "now" once — shared by all rolling-window plots this frame. + const globalPlotNow = getGlobalNow(); + + // Lightweight diagnostic: every ~10 s log buffer count + if (++_dbgTick % 300 === 0) { + let bufCount = 0, totalPts = 0; + Object.values(buffers).forEach(b => { if (b.size > 0) { bufCount++; totalPts += b.size; } }); + if (bufCount > 0) console.log(`[buf] ${bufCount} buffers, ${totalPts} total pts`); + } + + // Rolling-window mode: detect stale xRange (buffer has scrolled past a frozen + // zoom window) and clear it back to rolling. Run unconditionally before the + // data-rebuild loop so the stale plot is correctly handled this frame. + if (!trig.enabled && !globalPause) { + plots.forEach(p => { + if (!p.uplot || !p.xRange) return; + const hasData = p.traces.some(k => buffers[k] && buffers[k].size > 0); + if (!hasData) return; + const stale = p.traces.every(key => { + const buf = buffers[key]; + if (!buf || buf.size === 0) return true; + const oldest = buf.t[buf.size === buf.cap ? buf.head : 0]; + return p.xRange[1] < oldest; + }); + if (stale) { + p.xRange = null; + syncLocked = false; + const btnR = document.getElementById('btn-sync-resume'); + if (btnR) btnR.style.display = 'none'; + } + }); + } + + // Fast path: cursor-only redraw (no data rebuild needed) + if (cursorsDirty) { + cursorsDirty = false; + plots.forEach(p => { if (p.uplot) p.uplot.redraw(false); }); + } + + // Rolling-window plots: mark dirty every frame for smooth continuous scrolling. + // When no new data arrived since the last render, only advance the viewport + // via setScale instead of rebuilding all data arrays (much cheaper). + if (!trig.enabled && !globalPause) { + plots.forEach(p => { + if (!p.uplot || p.xRange || p.traces.length === 0) return; + p.needsRedraw = true; + }); + } + + plots.forEach(p => { + if (!p.needsRedraw || !p.uplot || p.traces.length === 0) return; + + const inTrigModeNow = trig.enabled && trig.snapshot !== null; + const isRolling = !trig.enabled && !p.xRange; + + // Fast path: rolling-window plot with no new data — just shift viewport + // anchored to buffer timestamps so the x-range only advances when + // signal data actually moves forward. + if (isRolling && _dataGen === p.lastDataGen && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length > 0) { + p.needsRedraw = false; + zoomGuard = true; + p.uplot.setScale('x', { min: globalPlotNow - windowSec, max: globalPlotNow }); + zoomGuard = false; + return; + } + + p.needsRedraw = false; + p.lastDataGen = _dataGen; + + const data = buildUPlotData(p, inTrigModeNow); + + // setData internally triggers the setScale hook in uPlot (it reaffirms the + // current scale even with auto:false). Keep zoomGuard raised across the + // entire setData + setScale block so the hook never calls onZoom and freezes + // p.xRange unintentionally. The guard is safe here: JS is single-threaded so + // no genuine user drag-zoom event can fire during this synchronous block. + zoomGuard = true; + p.uplot.setData(data); + + // Re-apply the x-scale after setData so the viewport stays correct. + if (trig.enabled && !inTrigModeNow) { + // Armed / waiting for trigger: keep the current scale frozen. + } else if (inTrigModeNow) { + const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); + const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec(); + p.uplot.setScale('x', { + min: p.xRange ? p.xRange[0] : -preS, + max: p.xRange ? p.xRange[1] : postS + }); + } else if (p.xRange) { + // Zoomed: re-apply so scale is correct after setData + p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] }); + } else { + // Rolling window: use same anchor as buildLiveData (min-of-max per source). + const plotNow = computePlotNow(p); + p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow }); + } + zoomGuard = false; + }); + + // Keep per-plot cursor value readouts in sync with live data. + if (cursors.mode === 'on') updatePlotCursorReadouts(); + + requestAnimationFrame(renderDirtyPlots); +} + + +/* ════════════════════════════════════════════════════════════════ + Global controls + ════════════════════════════════════════════════════════════════ */ +document.getElementById('btn-pause-global').addEventListener('click', () => { + globalPause = !globalPause; + const btn = document.getElementById('btn-pause-global'); + btn.textContent = globalPause ? '▶ Resume' : '⏸ Pause'; + btn.classList.toggle('active', globalPause); + if (!globalPause) plots.forEach(p => { p.needsRedraw = true; }); + updateCursorBtnVisibility(); +}); + +document.getElementById('window-select').addEventListener('change', e => { + windowSec = parseFloat(e.target.value); + // Grow any buffers that can't hold the full new window for their signal's rate. + Object.keys(buffers).forEach(key => { + const rate = getKeySamplingRate(key); + if (rate > 0) { + const needed = Math.min(MAX_CAP, Math.ceil(rate * windowSec * 1.5)); + if (needed > buffers[key].cap) buffers[key] = growBuffer(buffers[key], needed); + } + }); + // Evict rolling-mode LTTB cache — window size change invalidates all cached results. + plots.forEach(p => lttbCacheEvict(p.id)); + // Don't trigger redraws while in trigger mode without a snapshot + if (trig.enabled && !trig.snapshot) return; + plots.forEach(p => { if (!p.xRange) p.needsRedraw = true; }); +}); + +/* ════════════════════════════════════════════════════════════════ + Sidebar / right-panel strip — click to collapse, drag to resize + ════════════════════════════════════════════════════════════════ */ +let sidebarOpen = true; +function setSidebar(open) { + sidebarOpen = open; + document.getElementById('sidebar').classList.toggle('collapsed', !open); + const btn = document.getElementById('btn-sidebar'); + if (btn) btn.classList.toggle('active', open); + setTimeout(() => { + plots.forEach(p => { + if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); + }); + }, 200); +} +document.getElementById('btn-sidebar')?.addEventListener('click', () => setSidebar(!sidebarOpen)); + +(function() { + const DRAG_THRESHOLD = 4; // px before we treat as drag not click + function resizePlots() { + plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); }); + } + + function setupStrip(stripId, panelId, growDir) { + // growDir: 'right' → dragging right increases panel (left strip), + // 'left' → dragging left increases panel (right strip). + const strip = document.getElementById(stripId); + const panel = document.getElementById(panelId); + if (!strip || !panel) return; + + strip.addEventListener('mousedown', e => { + e.preventDefault(); + const startX = e.clientX; + const startW = panel.getBoundingClientRect().width; + let dragged = false; + + function onMove(ev) { + const dx = ev.clientX - startX; + if (!dragged && Math.abs(dx) < DRAG_THRESHOLD) return; + dragged = true; + const newW = growDir === 'right' ? startW + dx : startW - dx; + if (newW < 40) { + panel.classList.add('collapsed'); + panel.style.width = ''; + panel.style.minWidth = ''; + } else { + panel.classList.remove('collapsed'); + panel.style.width = newW + 'px'; + panel.style.minWidth = newW + 'px'; + } + resizePlots(); + } + + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (!dragged) { + // Pure click — toggle collapse + const nowCollapsed = panel.classList.toggle('collapsed'); + if (!nowCollapsed && parseInt(panel.style.width || '0') < 40) { + panel.style.width = ''; + panel.style.minWidth = ''; + } + setTimeout(resizePlots, 220); + } + if (panelId === 'sidebar') sidebarOpen = !panel.classList.contains('collapsed'); + } + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } + + setupStrip('left-strip', 'sidebar', 'right'); + setupStrip('right-strip', 'right-panel', 'left'); +})(); + +/* ════════════════════════════════════════════════════════════════ + Multi-source management + ════════════════════════════════════════════════════════════════ */ +function onSources(msg) { + const srcs = msg.sources || []; + const newIds = new Set(srcs.map(s => s.id)); + // Remove sources that disappeared. + Object.keys(sourcesMap).forEach(id => { + if (!newIds.has(id)) { + const prefix = id + ':'; + Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; }); + delete sourcesMap[id]; + } + }); + // Update or create entries. + srcs.forEach(s => { + if (!sourcesMap[s.id]) { + sourcesMap[s.id] = { id: s.id, label: s.label, addr: s.addr, state: s.state, signals: [] }; + } else { + Object.assign(sourcesMap[s.id], { label: s.label, addr: s.addr, state: s.state }); + } + }); + buildSidebar(); + if (statsOpen) _refreshStatsSelector(); + // If the server is already connected to MARTe2 (state replayed on WS reconnect), + // restore the connected UI without sending another connect command. + const debugSrc = srcs.find(s => s.id === 'debug'); + if (debugSrc && debugSrc.state === 'connected' && !marteConnected) { + onMarteConnected(); + } +} + +function addSourceWS(label, addr, multicastGroup, dataPort) { + if (ws && ws.readyState === WebSocket.OPEN) { + const msg = { type: 'addSource', label, addr }; + if (multicastGroup) { msg.multicastGroup = multicastGroup; } + if (dataPort) { msg.dataPort = dataPort; } + ws.send(JSON.stringify(msg)); + } +} + +function removeSource(id) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'removeSource', id })); + } +} + +function saveSourcesWS() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'saveSources' })); + } +} + +function makeAddSourceSection() { + const section = document.createElement('div'); + section.className = 'add-source-section'; + + const title = document.createElement('div'); + title.className = 'add-source-title'; + title.innerHTML = ' Add Source'; + + const body = document.createElement('div'); + body.className = 'add-source-body'; + + const addrInput = document.createElement('input'); + addrInput.className = 'add-src-input'; addrInput.type = 'text'; + addrInput.placeholder = 'host:port'; + + const labelInput = document.createElement('input'); + labelInput.className = 'add-src-input'; labelInput.type = 'text'; + labelInput.placeholder = 'label (optional)'; + + const mcastInput = document.createElement('input'); + mcastInput.className = 'add-src-input'; mcastInput.type = 'text'; + mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)'; + + const dataPortInput = document.createElement('input'); + dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number'; + dataPortInput.placeholder = 'data port (multicast only)'; + dataPortInput.min = '1'; dataPortInput.max = '65535'; + + const addBtn = document.createElement('button'); + addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect'; + addBtn.addEventListener('click', () => { + const addr = addrInput.value.trim(); if (!addr) return; + const mcastGroup = mcastInput.value.trim(); + const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0; + addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort); + addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = ''; + }); + addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); }); + + const saveBtn = document.createElement('button'); + saveBtn.className = 'add-src-btn save-src-btn'; + saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file'; + saveBtn.addEventListener('click', saveSourcesWS); + + body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn); + section.append(title, body); + + title.addEventListener('click', () => { + const open = section.classList.toggle('open'); + title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : ''; + }); + + return section; +} + +/* ════════════════════════════════════════════════════════════════ + Utility + ════════════════════════════════════════════════════════════════ */ +function escHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/* ════════════════════════════════════════════════════════════════ + VScale menu (left-click on badge) + ════════════════════════════════════════════════════════════════ */ +let _vsMenuKey = null, _vsMenuPlotId = null; + +function showVScaleMenu(key, plotId) { + hideSignalMenu(); + // If the toolbar was open for a different plot, hide that bar first. + if (_vsMenuPlotId != null && _vsMenuPlotId !== plotId) { + const oldBar = document.getElementById('vstb-' + _vsMenuPlotId); + if (oldBar) oldBar.style.display = 'none'; + } + _vsMenuKey = key; _vsMenuPlotId = plotId; + + const menu = document.getElementById('vscale-menu'); + const vs = getVScale(_vsMenuPlotId, key); + const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key; + document.getElementById('vscale-menu-key').textContent = displayName; + + document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => + btn.classList.toggle('active', btn.dataset.mode === vs.mode)); + + // Disable Range button when signal has no defined range. + const rangeBtn = document.querySelector('#vscale-mode-btns [data-mode="range"]'); + if (rangeBtn) { + const meta = findSignalMeta(key); + const hasRange = meta && meta.rangeMin != null && meta.rangeMax != null; + rangeBtn.disabled = !hasRange; + rangeBtn.title = hasRange ? '' : 'No range defined for this signal'; + } + + const isManual = vs.mode === 'manual'; + document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none'; + document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none'; + + // Pre-fill V/div with resolved or stored value; Position always shows current screenPos. + const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1); + document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1; + document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4)); + + // Type row (Analog/Digital) only shown in mixed mode. + const p = plots.find(q => q.id === plotId); + const isMixed = p && p.mode === 'mixed'; + document.getElementById('vscale-type-row').style.display = isMixed ? 'flex' : 'none'; + if (isMixed) { + document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => + btn.classList.toggle('active', btn.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog'))); + } + + // Move the toolbar div into this plot's vscale bar. + const bar = document.getElementById('vstb-' + plotId); + if (bar) { + bar.appendChild(menu); + bar.style.display = 'block'; + } + menu.style.display = 'block'; +} + +function hideVScaleMenu() { + const menu = document.getElementById('vscale-menu'); + // Return the menu element to body so it is detached from any plot card. + menu.style.display = 'none'; + document.body.appendChild(menu); + // Hide the vscale bar of the previously active plot. + if (_vsMenuPlotId != null) { + const bar = document.getElementById('vstb-' + _vsMenuPlotId); + if (bar) bar.style.display = 'none'; + } + _vsMenuKey = null; _vsMenuPlotId = null; +} + +/* ─── Array index picker ─────────────────────────────────────────────────── */ +let _aipOnConfirm = null, _aipOnCancel = null, _aipMaxIdx = 0; + +function showArrayIdxPicker(sigKey, n, onConfirm, onCancel) { + _aipOnConfirm = onConfirm; _aipOnCancel = onCancel; _aipMaxIdx = n - 1; + const menu = document.getElementById('array-idx-picker'); + const displayName = sigKey.includes(':') ? sigKey.split(':').slice(1).join(':') : sigKey; + document.getElementById('aip-sig').textContent = displayName; + document.getElementById('aip-range').textContent = '(0 – ' + (n - 1) + ')'; + const idxInput = document.getElementById('aip-idx'); + idxInput.max = n - 1; idxInput.value = 0; + menu.style.display = 'block'; + // Centre the picker on screen. + const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 120; + menu.style.left = Math.round((window.innerWidth - mw) / 2) + 'px'; + menu.style.top = Math.round((window.innerHeight - mh) / 3) + 'px'; + idxInput.focus(); idxInput.select(); +} + +function _aipConfirm() { + const idxInput = document.getElementById('aip-idx'); + const idx = Math.max(0, Math.min(_aipMaxIdx, parseInt(idxInput.value, 10) || 0)); + document.getElementById('array-idx-picker').style.display = 'none'; + if (_aipOnConfirm) _aipOnConfirm(idx); + _aipOnConfirm = _aipOnCancel = null; +} + +function _aipCancel() { + document.getElementById('array-idx-picker').style.display = 'none'; + if (_aipOnCancel) _aipOnCancel(); + _aipOnConfirm = _aipOnCancel = null; +} + +function initArrayIdxPicker() { + document.getElementById('aip-ok').addEventListener('click', _aipConfirm); + document.getElementById('aip-cancel').addEventListener('click', _aipCancel); + document.getElementById('aip-idx').addEventListener('keydown', e => { + if (e.key === 'Enter') _aipConfirm(); + if (e.key === 'Escape') _aipCancel(); + }); +} + +function initVScaleMenu() { + document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_vsMenuKey) return; + const vs = getVScale(_vsMenuPlotId, _vsMenuKey); + const newMode = btn.dataset.mode; + if (newMode === 'manual' && vs.mode !== 'manual') { + // Seed V/div from currently resolved value; screenPos stays as-is. + vs.divValue = vs._resolvedDiv || 1; + vs.offset = vs._resolvedOffset || 0; // keep for DC subtraction (internal) + document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4)); + document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4)); + } + vs.mode = newMode; + document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const isManual = vs.mode === 'manual'; + document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none'; + document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none'; + refreshPlotForKey(_vsMenuKey); + }); + }); + document.getElementById('vscale-vdiv').addEventListener('input', e => { + if (!_vsMenuKey) return; + const vs = getVScale(_vsMenuPlotId, _vsMenuKey); + vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30); + refreshPlotForKey(_vsMenuKey); + }); + // "Position (div)" moves the marker and signal together on screen. + document.getElementById('vscale-pos').addEventListener('input', e => { + if (!_vsMenuKey) return; + const vs = getVScale(_vsMenuPlotId, _vsMenuKey); + vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0)); + refreshPlotForKey(_vsMenuKey); + }); + // Type buttons (Analog / Digital) — only active in mixed mode. + document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_vsMenuKey || !_vsMenuPlotId) return; + const vs = getVScale(_vsMenuPlotId, _vsMenuKey); + vs.digitalInMixed = btn.dataset.type === 'digital'; + document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(b => + b.classList.toggle('active', b.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog'))); + // Rebuild needed because series rendering changes (digital = no spanGaps, etc.) + const p = plots.find(q => q.id === _vsMenuPlotId); + if (p) { createUPlot(p); p.needsRedraw = true; } + }); + }); + document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu); +} + +/* ════════════════════════════════════════════════════════════════ + Signal style context menu + ════════════════════════════════════════════════════════════════ */ +let _ctxMenuKey = null; + +function showSignalMenu(key, plotId, x, y) { + _ctxMenuKey = key; + const menu = document.getElementById('sig-ctx-menu'); + const style = getSigStyle(key); + document.getElementById('ctx-menu-key').textContent = key; + document.getElementById('ctx-color').value = style.color; + document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => { + btn.classList.toggle('active', parseFloat(btn.dataset.w) === style.width); + }); + document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.dash === style.dash); + }); + document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.marker === style.marker); + }); + document.getElementById('ctx-marker-size').value = style.markerSize; + document.getElementById('ctx-marker-size-val').textContent = style.markerSize + 'px'; + menu.style.display = 'block'; + const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 200; + menu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px'; +} + +function hideSignalMenu() { + document.getElementById('sig-ctx-menu').style.display = 'none'; + _ctxMenuKey = null; +} + +function initSignalMenu() { + document.getElementById('ctx-color').addEventListener('input', e => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { color: e.target.value }); + }); + document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { width: parseFloat(btn.dataset.w) }); + document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { dash: btn.dataset.dash }); + document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { marker: btn.dataset.marker }); + document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + document.getElementById('ctx-marker-size').addEventListener('input', e => { + const sz = parseInt(e.target.value, 10); + document.getElementById('ctx-marker-size-val').textContent = sz + 'px'; + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { markerSize: sz }); + }); + document.addEventListener('click', e => { + if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu(); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideSignalMenu(); hideVScaleMenu(); } }); +} + +/* ════════════════════════════════════════════════════════════════ + Source Statistics panel + ════════════════════════════════════════════════════════════════ */ +const sourceStats = {}; // sourceId → StatInfo (from backend 1 Hz message) +let statsOpen = false; +let statsSelectedSrc = null; // currently displayed source id + +function onStats(msg) { + const incoming = msg.sources || {}; + Object.keys(incoming).forEach(id => { sourceStats[id] = incoming[id]; }); + if (statsOpen) renderStats(); +} + +// Rebuild the source selector options; preserve selection when possible. +function _refreshStatsSelector() { + const sel = document.getElementById('stats-source-sel'); + if (!sel) return; + const prev = statsSelectedSrc; + sel.innerHTML = ''; + const srcs = Object.values(sourcesMap); + srcs.forEach(src => { + const opt = document.createElement('option'); + opt.value = src.id; + opt.textContent = src.label || src.id; + sel.appendChild(opt); + }); + // Restore previous selection or default to first + if (prev && sourcesMap[prev]) { + sel.value = prev; + } else if (srcs.length > 0) { + sel.value = srcs[0].id; + } + statsSelectedSrc = sel.value || null; +} + +// Frontend latency for one source: wallNow − newest calibrated buffer timestamp. +function sourceLatencyMs(srcId) { + const prefix = srcId + ':'; + const wallNow = Date.now() / 1000; + let best = null; + Object.keys(buffers).forEach(key => { + if (!key.startsWith(prefix)) return; + const buf = buffers[key]; + if (!buf || buf.size === 0) return; + const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + const lag = (wallNow - newest) * 1000; + if (best === null || lag < best) best = lag; + }); + return best; +} + +function _fmtMs(v) { return v != null && isFinite(v) ? v.toFixed(2) + ' ms' : '—'; } +function _fmtHz(v) { return v != null && isFinite(v) && v > 0 ? v.toFixed(2) + ' Hz' : '—'; } +function _fmtKB(v) { return v != null && isFinite(v) ? (v / 1024).toFixed(2) + ' KB' : '—'; } + +function _statsKV(label, value, cls) { + return `
${label}${value}
`; +} + +function _histHTML(si) { + if (!si.cycleHist || !si.cycleHist.length) return ''; + const maxC = Math.max(...si.cycleHist, 1); + const bars = si.cycleHist.map(c => { + const pct = Math.max(Math.round((c / maxC) * 100), 1); + return `
`; + }).join(''); + return `
+
${bars}
+
${si.cycleHistMin.toFixed(3)}${si.cycleHistMax.toFixed(3)} ms
+
`; +} + +function renderStats() { + const body = document.getElementById('stats-body'); + if (!body) return; + + const src = statsSelectedSrc ? sourcesMap[statsSelectedSrc] : null; + if (!src) { + body.innerHTML = 'No source selected'; + return; + } + + const si = sourceStats[src.id]; + const latMs = sourceLatencyMs(src.id); + const lossColor = si && si.totalLost > 0 ? 'warn' : 'ok'; + const lossText = si ? `${si.totalLost} / ${si.totalReceived}` : '—'; + + body.innerHTML = ` +
+ +
+ ${_statsKV('Address', src.addr)} + ${_statsKV('Latency', _fmtMs(latMs))} + ${_statsKV('Lost / Rx', lossText, lossColor)} + ${_statsKV('Loss %', si && si.totalReceived > 0 ? (si.totalLost / si.totalReceived * 100).toFixed(2) + ' %' : '—', si && si.totalLost > 0 ? 'warn' : 'ok')} +
+
+
+
+ +
+ ${_statsKV('avg', si ? _fmtHz(si.rateHz) : '—')} + ${_statsKV('± σ', si ? _fmtHz(si.rateStdHz) : '—')} + ${_statsKV('Pkts / cycle', si ? si.fragsPerCycle.toFixed(1) : '—')} + ${_statsKV('KB / cycle', si ? _fmtKB(si.bytesPerCycle) : '—')} +
+
+
+
+ +
+ ${_statsKV('avg', si ? _fmtMs(si.cycleAvgMs) : '—')} + ${_statsKV('σ', si ? _fmtMs(si.cycleStdMs) : '—')} + ${_statsKV('min', si ? _fmtMs(si.cycleMinMs) : '—')} + ${_statsKV('max', si ? _fmtMs(si.cycleMaxMs) : '—')} +
+ ${si ? _histHTML(si) : 'No data yet'} +
`; +} + +function toggleStats() { + statsOpen = !statsOpen; + document.getElementById('stats-panel').classList.toggle('open', statsOpen); + document.getElementById('btn-stats').classList.toggle('active', statsOpen); + if (statsOpen) { + _refreshStatsSelector(); + renderStats(); + } +} + +// Refresh latency and stats every second while panel is open. +setInterval(() => { if (statsOpen) renderStats(); }, 1000); + +/* ════════════════════════════════════════════════════════════════ + Init + ════════════════════════════════════════════════════════════════ */ +buildLayoutMenu(); +applyLayout('l1x1'); +buildSidebar(); // show "Add Source" section even before WS connection +initArrayIdxPicker(); +initVScaleMenu(); +initSignalMenu(); +document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV); +document.getElementById('btn-stats').addEventListener('click', toggleStats); +document.getElementById('btn-stats-close').addEventListener('click', toggleStats); +document.getElementById('stats-source-sel').addEventListener('change', e => { + statsSelectedSrc = e.target.value || null; + renderStats(); +}); +connectWS(); +requestAnimationFrame(renderDirtyPlots); +fetch('/version').then(r => r.text()).then(v => { + document.getElementById('build-version').textContent = 'v' + v; +}).catch(() => { }); + +/* ════════════════════════════════════════════════════════════════ + ══ DEBUG EXTENSION — MARTe2 control + signal trace/force/break + ════════════════════════════════════════════════════════════════ */ + +/* ── Small helpers ───────────────────────────────────────────────────── */ + +// HTML escape (alias for IO_components escHtml) +function esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// Current time string for log entries +function now() { + return new Date().toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3}); +} + +// Shorten a signal path for compact display +function shortName(name) { + if (!name) return ''; + const parts = name.split('.'); + return parts.length > 2 ? '…' + parts.slice(-2).join('.') : name; +} + +// Format a numeric value for debug display (avoids conflict with IO's fmtVal) +function _dbgFmtVal(v) { + if (v === undefined || v === null || !isFinite(v)) return '—'; + if (Math.abs(v) < 1e-3 && v !== 0) return v.toExponential(3); + if (Math.abs(v) >= 1e6) return v.toExponential(3); + return parseFloat(v.toPrecision(5)).toString(); +} + +// Get the last value of a signal from the IO_components buffer +function _debugLastVal(name) { + const key = 'debug:' + name; + const buf = buffers[key]; + if (!buf || buf.size === 0) return '—'; + const idx = (buf.head - 1 + buf.cap) % buf.cap; + return _dbgFmtVal(buf.v[idx]); +} + +// Make a small button (used in trace/break/forced lists) +function mkBtn(label, cls, fn) { + const b = document.createElement('button'); + b.className = 'tree-btn' + (cls ? ' ' + cls : ''); + b.textContent = label; + b.addEventListener('click', e => { e.stopPropagation(); fn(); }); + return b; +} + +// Debugger-specific color picker (separate from oscilloscope colors) +const _DBG_COLORS = [ + '#89b4fa','#f38ba8','#a6e3a1','#fab387','#f9e2af', + '#cba6f7','#89dceb','#b4befe','#eba0ac','#94e2d5' +]; +let _dbgColorIdx = 0; +function nextDebugColor() { return _DBG_COLORS[_dbgColorIdx++ % _DBG_COLORS.length]; } + +/* ── WebSocket send helpers ─────────────────────────────────────────── */ + +function sendWS(obj) { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); +} +function sendCmd(cmd) { sendWS({type:'cmd', data:{cmd}}); } + +/* ── Connection state handlers ──────────────────────────────────────── */ + +// Restore forced-signal state replayed by the server when a browser reconnects. +// Populates forcedSignals without re-sending FORCE commands to MARTe2. +function onForcedState(msg) { + const sigs = msg.signals || {}; + Object.assign(forcedSignals, sigs); + renderForcedTab(); +} + +// Restore traced-signal state replayed by the server when a browser reconnects. +// Populates tracedSet without re-sending TRACE commands to MARTe2. +function onTracedState(msg) { + const names = msg.signals || []; + names.forEach(n => tracedSet.add(n)); + renderTracedTab(); +} + +function onMarteConnected() { + marteConnected = true; + _clearStartupState(); + const el = document.getElementById('conn-status'); + if (el) el.classList.add('ok'); + const btn = document.getElementById('btn-connect'); + if (btn) btn.textContent = 'Disconnect'; + appendLog('sys', 'INFO', 'Connected to MARTe2'); + sendOnce('DISCOVER'); +} + +function onMarteDisconnected() { + marteConnected = false; + _clearStartupState(); + const el = document.getElementById('conn-status'); + if (el) el.classList.remove('ok'); + const btn = document.getElementById('btn-connect'); + if (btn) btn.textContent = 'Connect'; + appendLog('sys', 'WARNING', 'Disconnected from MARTe2'); + stopStepPoll(); +} + +/* ── Startup sequencing ─────────────────────────────────────────────── */ + +function sendOnce(cmd) { + if (_cmdSent.has(cmd)) return; + _cmdSent.add(cmd); + sendCmd(cmd); +} + +function _clearStartupState() { + _cmdSent.clear(); + _treeReady = false; + threads = []; + allObjects = []; + stopStepPoll(); + const tb = document.getElementById('tree-body'); + if (tb) tb.innerHTML = '
Connect to MARTe2 then click Tree
'; +} + +/* ── Message handlers ───────────────────────────────────────────────── */ + +function onResponse(msg) { + try { + const data = msg.data ? JSON.parse(msg.data) : null; + switch (msg.tag) { + case 'DISCOVER': + if (data) { + processDiscovery(data.Signals || []); + sendOnce('TREE'); + } + break; + case 'TREE': + if (data) onTreeNode(data); + break; + case 'INFO': + case 'CONFIG': + case 'SERVICE_INFO': + showInfo(msg.tag, msg.data); + break; + case 'STEP_STATUS': + processStepStatus(data); + break; + case 'VALUE': + processValue(data); + break; + } + } catch(e) { /* non-JSON responses silently ignored */ } +} + +function onTextLine(line) { + if (!line) return; + // Show TRACE/FORCE/BREAK responses so user can see match counts + if (line.startsWith('OK TRACE ') || line.startsWith('OK FORCE ') || + line.startsWith('OK BREAK ') || line.startsWith('OK UNFORCE ')) { + const parts = line.split(' '); + const count = parseInt(parts[2], 10); + const level = (count === 0) ? 'WARNING' : 'INFO'; + appendLog('sys', level, line + (count === 0 ? ' — no signals matched!' : '')); + return; + } + if (line.startsWith('OK ')) return; + appendLog('sys', 'DEBUG', line); +} + +function onLog(msg) { + let lv = msg.level || 'INFO'; + if (lv === 'Information') lv = 'INFO'; + else if (lv === 'Warning') lv = 'WARNING'; + else if (lv === 'Debug') lv = 'DEBUG'; + else if (lv === 'FatalError' || lv === 'ParametersError' || lv === 'OSError') lv = 'ERROR'; + appendLog(msg.time || now(), lv, msg.message || ''); +} + +function onServiceConfig(msg) { + const auto = document.getElementById('auto-ports'); + if (auto && auto.checked) { + if (msg.udp_port) { const el = document.getElementById('udp-port'); if (el) el.value = msg.udp_port; } + if (msg.log_port) { const el = document.getElementById('log-port'); if (el) el.value = msg.log_port; } + } + appendLog('sys', 'INFO', `ServiceConfig: UDP=${msg.udp_port} LOG=${msg.log_port}`); +} + +/* ── Discovery ──────────────────────────────────────────────────────── */ + +function processDiscovery(sigs) { + signalsById = {}; + signalsByName = {}; + for (const s of sigs) { + const el = s.elements || 1; + const allNames = (s.Names && s.Names.length > 0) ? s.Names : [s.name]; + const meta = {id: s.id, name: s.name, type: s.type || '', elements: el, + dimensions: s.dimensions || 0, names: allNames}; + signalsById[s.id] = meta; + for (const n of allNames) { + signalsByName[n] = meta; + if (el > 1) { + for (let i = 0; i < el; i++) signalsByName[`${n}[${i}]`] = meta; + } + } + } + updateSignalAutocomplete(); + appendLog('sys', 'INFO', `Discovered ${sigs.length} signals`); +} + +function updateSignalAutocomplete() { + const names = Object.keys(signalsByName).filter(n => !n.includes('[')); + ['force-sig-list', 'break-sig-list'].forEach(id => { + const dl = document.getElementById(id); + if (dl) dl.innerHTML = names.map(n => `' + + threads.map(t => ``).join(''); + } else if (el.tagName === 'DATALIST') { + el.innerHTML = threads.map(t => `