Initial release

This commit is contained in:
Martino Ferrari
2026-05-29 13:29:59 +02:00
commit 617b5bd712
110 changed files with 29234 additions and 0 deletions
+12
View File
@@ -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
+4
View File
@@ -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=
+66
View File
@@ -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)
}
}
+895
View File
@@ -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)
}
}
}
File diff suppressed because it is too large Load Diff
+514
View File
@@ -0,0 +1,514 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MARTe2 Integrated Client</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/uPlot.min.css">
<link rel="stylesheet" href="/style.css">
<script src="/uPlot.iife.min.js"></script>
</head>
<body>
<!-- ── Top bar ───────────────────────────────────────────────── -->
<div id="topbar">
<span id="app-title">MARTe2</span>
<div class="topbar-vsep"></div>
<!-- Debug connection menu -->
<div class="menu-wrap">
<button class="ctrl-btn" id="conn-menu-btn" onclick="toggleMenu('conn-dropdown')">
<span id="conn-status"></span>
Debug ▾
</button>
<div class="dropdown" id="conn-dropdown">
<div class="menu-row">
<input type="text" id="host" value="127.0.0.1" placeholder="host" style="flex:2">
<input type="number" id="port" value="8080" placeholder="ctrl" style="width:60px">
<button id="btn-connect" onclick="toggleConnect()">Connect</button>
</div>
<div class="menu-row" id="port-manual-row" style="display:none">
<label style="color:#a6adc8;font-size:11px;flex-shrink:0">UDP:</label>
<input type="number" id="udp-port" value="8081" placeholder="udp" style="width:72px">
<label style="color:#a6adc8;font-size:11px;flex-shrink:0">Log:</label>
<input type="number" id="log-port" value="8082" placeholder="log" style="width:72px">
</div>
<div class="menu-row">
<label class="form-check" style="margin:0;font-size:11px">
<input type="checkbox" id="auto-ports" checked onchange="toggleAutoPorts(this.checked)">
<span style="color:#a6adc8">Auto ports (SERVICE_INFO)</span>
</label>
</div>
<hr class="menu-sep">
<div class="menu-btn-row">
<button onclick="discoverCmd()">Discover</button>
<button onclick="treeCmd()">Tree</button>
<button onclick="serviceInfoCmd()">Info</button>
</div>
</div>
</div>
<button id="btn-pause" class="ctrl-btn" onclick="togglePause()">⏸ Pause</button>
<button class="ctrl-btn" onclick="openStepDialog()">⚙ Step</button>
<button class="ctrl-btn" onclick="openForceDialog()">⚡ Force</button>
<button class="ctrl-btn" onclick="openBreakDialog()">Break</button>
<button class="ctrl-btn" onclick="openMsgDialog()">✉ Msg</button>
<div class="topbar-vsep"></div>
<!-- Scope controls -->
<button id="btn-layout" class="ctrl-btn layout-toggle" title="Select layout">⊞ 1×1 ▾</button>
<div class="topbar-sep"></div>
<div id="cursor-readout">
<span id="cur-ta">A: —</span><span class="cur-sep"></span>
<span id="cur-tb">B: —</span><span class="cur-sep"></span>
<span id="cur-dt">ΔT: —</span>
</div>
<span class="ctrl-label" id="lbl-window">Window:</span>
<select id="window-select" class="ctrl-select">
<option value="1">1 s</option><option value="5" selected>5 s</option>
<option value="10">10 s</option><option value="30">30 s</option>
<option value="60">60 s</option>
</select>
<button id="btn-cursor" class="ctrl-btn" style="display:none">Cursor</button>
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
<button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button>
<button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button>
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
<div class="topbar-vsep"></div>
<span id="udp-stats" style="color:#585b70;font-size:11px">0 pkts</span>
<div id="status-led"></div>
<span id="status-text" style="font-size:11px;color:#a6adc8">Disconnected</span>
<span id="sb-tsage" style="font-size:11px;color:#585b70"></span>
<button id="btn-stats" class="ctrl-btn" style="height:20px;padding:0 7px;font-size:10px;line-height:1">Stats</button>
<span id="build-version" style="font-size:10px;color:#585b70;margin-left:4px"></span>
</div>
<!-- ── Trigger bar ───────────────────────────────────────────── -->
<div id="trigbar">
<div class="trig-group">
<span class="trig-label">Signal</span>
<select id="trig-signal" class="trig-select"><option value="">— none —</option></select>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Edge</span>
<select id="trig-edge" class="trig-select">
<option value="rising">Rising ↑</option>
<option value="falling">Falling ↓</option>
<option value="both">Both ↕</option>
</select>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Threshold</span>
<input id="trig-threshold" class="trig-input" type="number" value="0" step="any">
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Window</span>
<select id="trig-window" class="trig-select">
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option>
<option value="0.01">10 ms</option><option value="0.1">100 ms</option>
<option value="0.5">500 ms</option><option value="1" selected>1 s</option>
<option value="5">5 s</option><option value="10">10 s</option>
</select>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Pre</span>
<input id="trig-pre" class="trig-range" type="range" min="0" max="100" value="20">
<span class="trig-range-val" id="trig-pre-val">20%</span>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Mode</span>
<select id="trig-mode" class="trig-select">
<option value="normal">Normal</option>
<option value="single">Single</option>
</select>
</div>
<div class="trig-sep"></div>
<div class="trig-group" style="gap:8px">
<span id="trig-status-badge">IDLE</span>
<button id="btn-trig-stop" style="display:none">Stop</button>
<button id="btn-trig-rearm">Rearm</button>
</div>
</div>
<!-- ── Body ─────────────────────────────────────────────────── -->
<div id="body">
<!-- ── Step status bar (inside body, visible when paused) ── -->
<div id="step-bar">
<span>⏹ PAUSED at <b id="paused-gam"></b></span>
<span id="step-remaining"></span>
<select id="step-thread" style="width:120px"></select>
<button onclick="step(1)">Step 1</button>
<button onclick="step(5)">Step 5</button>
<button onclick="step(20)">Step 20</button>
<button class="ok" onclick="togglePause()">▶ Resume</button>
</div>
<div id="body-row">
<!-- LEFT: signal list (scope) + object tree (debugger) -->
<div id="sidebar">
<div class="panel-tabs">
<div class="panel-tab active" onclick="switchSidebarTab('tree')">Object Tree</div>
</div>
<!-- Signals tab (hidden — signals come from Traced panel in debugger) -->
<div id="sidebar-signals" class="sidebar-tab-body" style="display:none">
<div id="signal-list"></div>
</div>
<!-- Object tree tab -->
<div id="sidebar-tree" class="sidebar-tab-body active">
<div class="panel-search">
<input id="tree-search" oninput="filterTree(this.value)" placeholder="Search tree…">
</div>
<div class="panel-body" id="tree-body">
<div class="empty-hint">Connect to MARTe2 then click Tree</div>
</div>
</div>
</div>
<!-- Left resize/collapse strip -->
<div id="left-strip" class="panel-strip" title="Drag to resize · Click to collapse"></div>
<!-- CENTER: plots -->
<div id="main">
<div id="plot-grid" class="l1x1"></div>
</div>
<!-- Right resize/collapse strip -->
<div id="right-strip" class="panel-strip" title="Drag to resize · Click to collapse"></div>
<!-- RIGHT: debug tabs (Traced, Forced, Breaks, Msgs) -->
<div id="right-panel">
<div class="tabs">
<div class="tab active" onclick="switchTab('traced')">Traced</div>
<div class="tab" onclick="switchTab('forced')">Forced</div>
<div class="tab" onclick="switchTab('breaks')">Breaks</div>
<div class="tab" onclick="switchTab('msgs')">Msgs</div>
</div>
<div class="tab-content active" id="tab-traced">
<div class="empty-hint" id="no-traced-hint">No signals traced</div>
</div>
<div class="tab-content" id="tab-forced">
<div class="empty-hint" id="no-forced-hint">No signals forced</div>
</div>
<div class="tab-content" id="tab-breaks">
<div class="empty-hint" id="no-breaks-hint">No breakpoints set</div>
</div>
<div class="tab-content" id="tab-msgs">
<div class="empty-hint" id="no-msgs-hint">No messages sent</div>
</div>
</div>
</div><!-- #body-row -->
<!-- ── Bottom: Logs ─────────────────────────────────────────── -->
<div id="log-panel">
<div id="log-toolbar">
<button class="panel-toggle" title="Collapse logs" onclick="togglePanel('log-panel','▼','▲',this)" id="log-toggle-btn"></button>
<span style="font-weight:600;color:#89b4fa">Logs</span>
<label><input type="checkbox" id="lf-service" onchange="renderLogs()"> Service</label>
<label><input type="checkbox" id="lf-debug" checked onchange="renderLogs()"> Debug</label>
<label><input type="checkbox" id="lf-info" checked onchange="renderLogs()"> Info</label>
<label><input type="checkbox" id="lf-warn" checked onchange="renderLogs()"> Warn</label>
<label><input type="checkbox" id="lf-error" checked onchange="renderLogs()"> Error</label>
<input type="text" id="log-filter" placeholder="Filter…" oninput="renderLogs()" style="width:150px">
<button onclick="logs=[];renderLogs()" style="margin-left:auto">Clear</button>
<label><input type="checkbox" id="log-autoscroll" checked> Auto-scroll</label>
</div>
<div id="log-body"></div>
</div><!-- #log-panel -->
</div><!-- #body -->
<!-- ── Stats panel ─────────────────────────────────────────── -->
<div id="stats-panel">
<div id="stats-panel-hdr">
<span class="stats-hdr-label">Source Statistics</span>
<select id="stats-source-sel" class="stats-source-sel"></select>
<button id="btn-stats-close"></button>
</div>
<div id="stats-body"></div>
</div>
<div id="layout-menu"></div>
<!-- ── Signal style context menu ─────────────────────────────── -->
<div id="sig-ctx-menu" style="display:none">
<div class="ctx-menu-header">Style:
<span id="ctx-menu-key" class="ctx-menu-key"></span></div>
<div class="ctx-row">
<label>Color</label>
<input type="color" id="ctx-color">
</div>
<div class="ctx-row">
<label>Width</label>
<div class="ctx-btns" id="ctx-width-btns">
<button class="ctx-btn" data-w="1">1px</button>
<button class="ctx-btn active" data-w="1.5">1.5</button>
<button class="ctx-btn" data-w="2">2px</button>
<button class="ctx-btn" data-w="3">3px</button>
</div>
</div>
<div class="ctx-row">
<label>Line</label>
<div class="ctx-btns" id="ctx-dash-btns">
<button class="ctx-btn active" data-dash="solid">——</button>
<button class="ctx-btn" data-dash="dashed">╌╌</button>
<button class="ctx-btn" data-dash="dotted">·····</button>
</div>
</div>
<div class="ctx-row">
<label>Marker</label>
<div class="ctx-btns" id="ctx-marker-btns">
<button class="ctx-btn active" data-marker="none">none</button>
<button class="ctx-btn" data-marker="circle"></button>
<button class="ctx-btn" data-marker="square"></button>
<button class="ctx-btn" data-marker="cross"></button>
<button class="ctx-btn" data-marker="diamond"></button>
</div>
</div>
<div class="ctx-row">
<label>Size</label>
<input type="range" class="ctx-range" id="ctx-marker-size" min="2" max="10" value="4">
<span class="ctx-range-val" id="ctx-marker-size-val">4px</span>
</div>
</div>
<!-- ── Array index picker (trigger signal) ──────────────────── -->
<div id="array-idx-picker" style="display:none">
<div class="ctx-menu-header">Element index: <span id="aip-sig" class="ctx-menu-key"></span></div>
<div class="ctx-row">
<label>Index</label>
<input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0">
<span id="aip-range" style="font-size:10px;color:var(--overlay0)"></span>
</div>
<div class="ctx-row" style="justify-content:flex-end;gap:6px">
<button class="ctx-btn" id="aip-cancel">Cancel</button>
<button class="ctx-btn active" id="aip-ok">OK</button>
</div>
</div>
<!-- ── VScale toolbar (moved into plot card when active) ─────── -->
<div id="vscale-menu" style="display:none">
<div class="vstb-header">
<span class="vstb-label">V-Scale: <span id="vscale-menu-key" class="ctx-menu-key"></span></span>
<div class="ctx-btns" id="vscale-mode-btns">
<button class="ctx-btn active" data-mode="auto">Auto</button>
<button class="ctx-btn" data-mode="range">Range</button>
<button class="ctx-btn" data-mode="manual">Manual</button>
</div>
<div id="vscale-manual-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">V/div</label>
<input type="number" id="vscale-vdiv" class="ctx-num" min="1e-30" step="any" value="1">
</div>
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Pos</label>
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
</div>
<div id="vscale-type-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Type</label>
<div class="ctx-btns" id="vscale-type-btns">
<button class="ctx-btn active" data-type="analog">Analog</button>
<button class="ctx-btn" data-type="digital">Digital</button>
</div>
</div>
<button id="btn-vscale-close" class="vstb-close" title="Close"></button>
</div>
</div>
<!-- ═══ DIALOGS ═══ -->
<!-- Force dialog -->
<div class="dialog-overlay" id="dlg-force" style="display:none" onclick="if(event.target===this)closeDlg('dlg-force')">
<div class="dialog">
<h3>Force Signal</h3>
<label>Signal</label>
<input id="force-sig" list="force-sig-list" placeholder="Signal path…">
<datalist id="force-sig-list"></datalist>
<label>Value</label>
<input id="force-val" placeholder="e.g. 42">
<div class="btns">
<button onclick="closeDlg('dlg-force')">Cancel</button>
<button class="active" onclick="doForce()">Force</button>
</div>
</div>
</div>
<!-- Unforce confirm -->
<div class="dialog-overlay" id="dlg-unforce" style="display:none" onclick="if(event.target===this)closeDlg('dlg-unforce')">
<div class="dialog">
<h3>Remove Force</h3>
<p id="unforce-msg" style="margin-bottom:12px;color:#cdd6f4"></p>
<div class="btns">
<button onclick="closeDlg('dlg-unforce')">Cancel</button>
<button class="danger" onclick="doUnforce()">Unforce</button>
</div>
</div>
</div>
<!-- Break dialog -->
<div class="dialog-overlay" id="dlg-break" style="display:none" onclick="if(event.target===this)closeDlg('dlg-break')">
<div class="dialog">
<h3>Set Breakpoint</h3>
<label>Signal</label>
<input id="break-sig" list="break-sig-list" placeholder="Signal path…">
<datalist id="break-sig-list"></datalist>
<div class="form-row">
<div>
<label>Operator</label>
<select id="break-op">
<option>></option><option>&gt;=</option>
<option>&lt;</option><option>&lt;=</option>
<option>==</option><option>!=</option>
</select>
</div>
<div>
<label>Threshold</label>
<input id="break-thresh" placeholder="0">
</div>
</div>
<div class="btns">
<button onclick="closeDlg('dlg-break')">Cancel</button>
<button class="active" onclick="doBreak()">Set Break</button>
</div>
</div>
</div>
<!-- Message dialog -->
<div class="dialog-overlay" id="dlg-msg" style="display:none" onclick="if(event.target===this)closeDlg('dlg-msg')">
<div class="dialog">
<h3>Send Message</h3>
<label>Destination</label>
<input id="msg-dest" list="msg-dest-list" placeholder="e.g. App.Functions.GAM1">
<datalist id="msg-dest-list"></datalist>
<label>Function</label>
<input id="msg-func" placeholder="FunctionName">
<label>Payload (key=value lines)</label>
<textarea id="msg-payload" placeholder="Key = Value&#10;Key2 = Value2"></textarea>
<div class="form-check">
<input type="checkbox" id="msg-wait">
<label for="msg-wait">Wait for reply</label>
</div>
<div class="btns">
<button onclick="closeDlg('dlg-msg')">Cancel</button>
<button class="active" onclick="doSendMsg()">Send</button>
</div>
</div>
</div>
<!-- Array signal dialog (Trace / Force / Break with index/range selection) -->
<div class="dialog-overlay" id="dlg-array" style="display:none" onclick="if(event.target===this)closeDlg('dlg-array')">
<div class="dialog" style="min-width:360px">
<h3 id="arr-dlg-title">Array Signal</h3>
<p style="font-size:11px;color:#a6adc8;margin-bottom:14px">
<b id="arr-dlg-sig" style="color:#cdd6f4"></b>
&nbsp;·&nbsp;<span id="arr-dlg-n" style="color:#89b4fa"></span> elements
</p>
<label style="margin-bottom:6px">Apply to</label>
<div class="arr-seg">
<button id="arr-sel-all" class="active" onclick="setArrSel('all')">All</button>
<button id="arr-sel-idx" onclick="setArrSel('idx')">Index</button>
<button id="arr-sel-rng" onclick="setArrSel('rng')">Range</button>
</div>
<div id="arr-idx-sect" style="display:none">
<label>Element index &nbsp;<span style="color:#585b70;font-weight:normal">(0 <span id="arr-idx-max"></span>)</span></label>
<input id="arr-idx" type="number" min="0" value="0">
</div>
<div id="arr-rng-sect" style="display:none">
<div class="form-row">
<div>
<label>From index</label>
<input id="arr-r0" type="number" min="0" value="0">
</div>
<div>
<label>To index <span style="color:#585b70;font-weight:normal">inclusive</span></label>
<input id="arr-r1" type="number" min="0" value="0">
</div>
</div>
</div>
<div id="arr-force-sect" style="display:none">
<label>Force value</label>
<input id="arr-force-val" placeholder="e.g. 0">
</div>
<div id="arr-break-sect" style="display:none">
<div class="form-row">
<div>
<label>Condition</label>
<select id="arr-break-op">
<option>></option><option>>=</option>
<option>&lt;</option><option>&lt;=</option>
<option>==</option><option>!=</option>
</select>
</div>
<div>
<label>Threshold</label>
<input id="arr-break-thr" placeholder="0">
</div>
</div>
</div>
<div class="btns">
<button onclick="closeDlg('dlg-array')">Cancel</button>
<button id="arr-ok-btn" class="active" onclick="doArrayOp()">Apply</button>
</div>
</div>
</div>
<!-- Array → plot mode dialog (Sequential / Waterfall) -->
<div class="dialog-overlay" id="dlg-arr-plot" style="display:none" onclick="if(event.target===this)closeDlg('dlg-arr-plot')">
<div class="dialog" style="min-width:360px">
<h3>Plot Array Signal</h3>
<p style="font-size:11px;color:#a6adc8;margin-bottom:14px">
<b id="arrp-name" style="color:#cdd6f4"></b>
&nbsp;·&nbsp;<span id="arrp-n" style="color:#89b4fa"></span> elements
</p>
<div class="arr-seg" style="margin-bottom:8px">
<button id="arrp-sel-sequential" class="active" onclick="setArrpMode('sequential')">Sequential</button>
<button id="arrp-sel-waterfall" onclick="setArrpMode('waterfall')">Waterfall</button>
</div>
<p id="arrp-desc" style="font-size:10px;color:#a6adc8;margin-bottom:16px;min-height:28px"></p>
<div class="btns">
<button onclick="closeDlg('dlg-arr-plot')">Cancel</button>
<button class="active" onclick="doArrayPlotMode()">Add</button>
</div>
</div>
</div>
<!-- Info dialog -->
<div class="dialog-overlay" id="dlg-info" style="display:none" onclick="if(event.target===this)closeDlg('dlg-info')">
<div class="dialog" style="max-width:600px;width:90vw">
<h3 id="info-title">Info</h3>
<pre id="info-body" style="background:#11111b;padding:8px;border-radius:4px;overflow:auto;max-height:400px;font-size:11px;color:#cdd6f4;white-space:pre-wrap"></pre>
<div class="btns" style="margin-top:12px">
<button onclick="closeDlg('dlg-info')">Close</button>
</div>
</div>
</div>
<!-- Step dialog -->
<div class="dialog-overlay" id="dlg-step" style="display:none" onclick="if(event.target===this)closeDlg('dlg-step')">
<div class="dialog">
<h3>Step Execution</h3>
<div class="form-row">
<div>
<label>Count</label>
<input type="number" id="step-count" value="1" min="1">
</div>
<div>
<label>Thread (optional)</label>
<input id="step-thread-inp" list="step-thread-list" placeholder="all">
<datalist id="step-thread-list"></datalist>
</div>
</div>
<div class="btns">
<button onclick="closeDlg('dlg-step')">Cancel</button>
<button class="active" onclick="doStep()">Step</button>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
'use strict';
// LTTB (Largest Triangle Three Buckets) decimation — O(n).
// Runs off-main-thread to avoid blocking the render loop.
function lttb(t, v, threshold) {
const len = t.length;
if (len <= threshold) {
// Copy to new arrays so we can transfer them back without detaching the input.
return { t: new Float64Array(t), v: new Float64Array(v) };
}
const outT = new Float64Array(threshold);
const 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;
const 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;
const 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 };
}
self.onmessage = function({ data: { id, t, v, threshold } }) {
const result = lttb(t, v, threshold);
// Transfer the output buffers back to the main thread zero-copy.
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
};
+711
View File
@@ -0,0 +1,711 @@
/* ── Catppuccin Mocha palette ──────────────────────────────── */
:root {
--bg: #1e1e2e; --mantle: #181825; --crust: #11111b;
--surface0: #313244; --surface1: #45475a; --surface2: #585b70;
--overlay0: #6c7086; --overlay1: #7f849c; --text: #cdd6f4;
--subtext0: #a6adc8; --subtext1: #bac2de;
--accent: #89b4fa; --green: #a6e3a1; --red: #f38ba8;
--yellow: #f9e2af; --peach: #fab387; --mauve: #cba6f7;
--teal: #94e2d5; --sky: #89dceb; --lavender: #b4befe;
--pink: #f5c2e7;
--radius: 8px; --sidebar-w: 280px; --topbar-h: 52px;
--trigbar-h: 0px; --statusbar-h: 0px; --transition: 0.18s ease;
--border: var(--surface0); --fg: var(--text); --fg2: var(--subtext0);
--bg2: var(--mantle); --bg3: var(--surface0);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height:100%; background:var(--bg); color:var(--text);
font-family:'Segoe UI',system-ui,sans-serif; font-size:14px; overflow:hidden; }
::-webkit-scrollbar { width:6px; }
::-webkit-scrollbar-track { background:var(--mantle); }
::-webkit-scrollbar-thumb { background:var(--surface1); border-radius:3px; }
/* ── Global element overrides ────────────────────────────── */
button { background:var(--surface0); border:1px solid var(--surface1); color:var(--text); padding:2px 8px; border-radius:4px; cursor:pointer; white-space:nowrap; }
button:hover { background:var(--surface1); }
button.active { background:var(--accent); color:var(--crust); border-color:var(--accent); }
button.danger { background:var(--red); color:var(--crust); border-color:var(--red); }
button.warn { background:var(--peach); color:var(--crust); border-color:var(--peach); }
button.ok { background:var(--green); color:var(--crust); border-color:var(--green); }
select { background:var(--surface0); border:1px solid var(--surface1); color:var(--text); padding:2px 4px; border-radius:4px; outline:none; cursor:pointer; }
select:hover { border-color:var(--accent); }
input[type=text], input[type=number] {
background:var(--surface0); border:1px solid var(--surface1); color:var(--text);
padding:2px 6px; border-radius:4px; outline:none;
}
input[type=text]:focus, input[type=number]:focus { border-color:var(--accent); }
/* ── uPlot overrides ─────────────────────────────────────────── */
.uplot { background:transparent !important; }
.uplot .u-over { cursor: crosshair; }
.uplot .u-cursor-x, .uplot .u-cursor-y { border-color: #585b70 !important; }
.uplot .u-select { background: rgba(137,180,250,0.1) !important; border: 1px solid rgba(137,180,250,0.4) !important; }
/* ── Top bar ─────────────────────────────────────────────────── */
#topbar {
position:fixed; top:0; left:0; right:0; height:var(--topbar-h);
background:var(--mantle); border-bottom:1px solid var(--surface0);
display:flex; align-items:center; gap:10px; padding:0 14px;
z-index:100; box-shadow:0 2px 8px rgba(0,0,0,0.4);
}
#app-title { font-weight:700; font-size:15px; color:var(--accent);
white-space:nowrap; letter-spacing:0.3px; flex-shrink:0; }
.topbar-sep { flex:1; min-width:4px; }
#status-led { width:8px; height:8px; border-radius:50%;
background:var(--red); flex-shrink:0; transition:background var(--transition); }
#status-led.green { background:var(--green); animation:pulse-green 2s infinite; }
#status-led.orange { background:var(--yellow); animation:pulse-orange 1.5s infinite; }
@keyframes pulse-green { 0%,100%{box-shadow:0 0 0 0 rgba(166,227,161,0.4)} 50%{box-shadow:0 0 0 4px rgba(166,227,161,0)} }
@keyframes pulse-orange { 0%,100%{box-shadow:0 0 0 0 rgba(249,226,175,0.4)} 50%{box-shadow:0 0 0 4px rgba(249,226,175,0)} }
#status-text { font-size:11px; color:var(--subtext0); white-space:nowrap; }
#sb-tsage { font-size:11px; color:var(--overlay0); white-space:nowrap; font-family:monospace; }
#cursor-readout {
display:none; align-items:center; gap:8px;
font-size:11px; font-family:monospace;
background:var(--surface0); border:1px solid var(--surface1);
border-radius:5px; padding:3px 8px; white-space:nowrap; flex-shrink:0;
}
#cursor-readout.visible { display:flex; }
#cur-ta { color:var(--sky); } #cur-tb { color:var(--yellow); }
#cur-dt { color:var(--subtext1); } .cur-sep { color:var(--surface2); }
.topbar-vsep { width:1px; height:22px; background:var(--surface0); flex-shrink:0; margin:0 2px; }
#layout-btns { display:flex; gap:2px; align-items:center; flex-shrink:0; }
.ctrl-label { font-size:12px; color:var(--subtext0); white-space:nowrap; flex-shrink:0; }
select.ctrl-select {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:var(--radius);
padding:4px 6px; font-size:12px; cursor:pointer; outline:none; flex-shrink:0;
}
select.ctrl-select:hover { border-color:var(--accent); }
button.ctrl-btn {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:var(--radius);
padding:4px 12px; font-size:12px; cursor:pointer; white-space:nowrap; flex-shrink:0;
transition:background var(--transition),border-color var(--transition);
}
button.ctrl-btn:hover { background:var(--surface1); border-color:var(--accent); }
button.ctrl-btn.active { background:var(--surface1); border-color:var(--accent); color:var(--accent); }
button.ctrl-btn.trig-active { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
button.ctrl-btn.cursor-a { border-color:var(--sky); color:var(--sky); }
button.ctrl-btn.cursor-b { border-color:var(--yellow); color:var(--yellow); }
button.ctrl-btn.resume-btn { border-color:var(--teal); color:var(--teal); }
/* ── Connection dropdown (debug menu) ─────────────────────── */
.menu-wrap { position:relative; }
.dropdown {
position:absolute; top:calc(100% + 4px); left:0; z-index:300;
background:var(--mantle); border:1px solid var(--surface1);
border-radius:var(--radius); box-shadow:0 6px 20px rgba(0,0,0,.5);
padding:6px; min-width:280px; display:none; flex-direction:column; gap:6px;
}
.dropdown.open { display:flex; }
.menu-row { display:flex; align-items:center; gap:6px; }
.menu-row input[type=text], .menu-row input[type=number] { font-size:11px; }
.menu-row label { font-size:11px; }
.menu-btn-row { display:flex; gap:4px; }
.menu-btn-row button { flex:1; font-size:11px; }
.menu-sep { border:none; border-top:1px solid var(--surface1); margin:2px 0; }
#conn-status { width:8px; height:8px; border-radius:50%; background:var(--red); display:inline-block; flex-shrink:0; vertical-align:middle; }
#conn-status.ok { background:var(--green); }
/* ── Trigger bar ──────────────────────────────────────────────── */
#trigbar {
position:fixed; top:var(--topbar-h); left:0; right:0;
background:var(--crust); border-bottom:1px solid var(--surface0);
display:flex; align-items:center; flex-wrap:wrap; gap:10px;
padding:0 16px; z-index:99;
height:0; overflow:hidden;
transition:height var(--transition),padding var(--transition);
}
#trigbar.open { height:48px; padding:0 16px; }
.trig-group { display:flex; align-items:center; gap:6px; }
.trig-sep { width:1px; height:24px; background:var(--surface0); flex-shrink:0; }
.trig-label { font-size:11px; color:var(--subtext0); white-space:nowrap; }
select.trig-select {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:5px;
padding:3px 6px; font-size:12px; cursor:pointer; outline:none;
}
select.trig-select:hover { border-color:var(--mauve); }
input.trig-input {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:5px;
padding:3px 6px; font-size:12px; outline:none; width:80px;
}
input.trig-input:focus { border-color:var(--mauve); }
input[type=range].trig-range {
-webkit-appearance:none; width:90px; height:4px;
background:var(--surface1); border-radius:2px; outline:none; cursor:pointer;
}
input[type=range].trig-range::-webkit-slider-thumb {
-webkit-appearance:none; width:12px; height:12px;
border-radius:50%; background:var(--mauve); cursor:pointer;
}
.trig-range-val { font-size:11px; color:var(--mauve); min-width:28px; }
#trig-status-badge {
font-size:11px; font-weight:700; letter-spacing:0.8px;
padding:3px 10px; border-radius:12px;
border:1px solid var(--surface1); background:var(--surface0); color:var(--subtext0);
min-width:80px; text-align:center; white-space:nowrap;
}
#trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); }
#trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); }
#trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
#btn-trig-rearm, #btn-trig-stop {
border:none; border-radius:5px;
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer; display:none;
}
#btn-trig-rearm { background:var(--mauve); color:var(--crust); }
#btn-trig-stop { background:var(--surface1); color:var(--yellow); border:1px solid var(--yellow); }
#btn-trig-rearm:hover, #btn-trig-stop:hover { opacity:0.85; }
/* ── Body: outer flex-column container ───────────────────── */
#body {
position:fixed;
top:calc(var(--topbar-h) + var(--trigbar-h));
left:0; right:0; bottom:0;
display:flex; flex-direction:column; overflow:hidden;
transition:top var(--transition);
}
/* ── Step status bar (inside #body, at top) ──────────────── */
#step-bar {
background:rgba(45,43,69,0.95); border-bottom:1px solid var(--surface0);
padding:4px 8px; display:none; align-items:center; gap:8px;
flex-shrink:0; font-size:11px;
}
#step-bar.visible { display:flex; }
#step-bar button { font-size:11px; }
#step-bar button.ok { background:var(--green); color:var(--crust); border-color:var(--green); }
/* ── Inner horizontal row: sidebar | strip | main | strip | right-panel */
#body-row {
display:flex; flex-direction:row; flex:1; min-height:0; overflow:hidden;
}
/* ── Sidebar ──────────────────────────────────────────────────── */
#sidebar {
width:var(--sidebar-w); min-width:var(--sidebar-w);
background:var(--mantle); border-right:1px solid var(--surface0);
display:flex; flex-direction:column;
transition:width var(--transition),min-width var(--transition); overflow:hidden;
}
#sidebar.collapsed { width:0; min-width:0; }
#sidebar-header {
display:flex; align-items:center; justify-content:space-between;
padding:10px 14px; border-bottom:1px solid var(--surface0);
font-weight:600; color:var(--subtext1); font-size:12px;
text-transform:uppercase; letter-spacing:0.8px; flex-shrink:0;
}
/* ── Sidebar dual-tab (Signals / Object Tree) ─────────────── */
.panel-tabs {
display:flex; border-bottom:1px solid var(--surface0); flex-shrink:0;
}
.panel-tab {
flex:1; padding:4px 6px; text-align:center; cursor:pointer;
color:var(--subtext0); font-size:11px; font-weight:500;
border-bottom:2px solid transparent;
transition:color var(--transition), border-color var(--transition);
}
.panel-tab:hover { color:var(--subtext1); }
.panel-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.sidebar-tab-body { display:none; flex:1; flex-direction:column; overflow:hidden; }
.sidebar-tab-body.active { display:flex; }
.panel-search { padding:4px; flex-shrink:0; border-bottom:1px solid var(--surface0); }
.panel-search input { width:100%; font-size:11px; }
.panel-body { flex:1; overflow-y:auto; padding:4px; }
/* ── Signal list ──────────────────────────────────────────── */
#signal-list { flex:1; overflow-y:auto; padding:8px 0; }
.sig-item {
padding:6px 14px; cursor:grab; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:8px;
user-select:none;
}
.sig-item:hover { background:var(--surface0); }
.sig-item:active { cursor:grabbing; }
.sig-item.dragging { opacity:0.4; }
.sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; }
.type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; }
.array-group {}
.array-header {
padding:6px 14px 6px 10px; cursor:pointer; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:6px; user-select:none;
}
.array-header:hover { background:var(--surface0); }
.array-arrow { font-size:10px; color:var(--subtext0); transition:transform var(--transition); display:inline-block; }
.array-header.open .array-arrow { transform:rotate(90deg); }
.array-children { display:none; padding-left:16px; }
.array-header.open + .array-children { display:block; }
.array-child {
padding:4px 14px 4px 8px; cursor:grab; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:8px;
user-select:none; color:var(--subtext1); font-size:12px;
}
.array-child:hover { background:var(--surface0); }
.array-child:active { cursor:grabbing; }
/* ── Resize / collapse strips ─────────────────────────────── */
.panel-strip {
width:6px; flex-shrink:0; cursor:col-resize;
background:var(--mantle); border:none; position:relative;
transition:background 0.1s;
}
.panel-strip::after {
content:''; position:absolute; top:50%; left:50%;
transform:translate(-50%,-50%); width:2px; height:32px;
background:var(--surface1); border-radius:1px; pointer-events:none;
}
.panel-strip:hover { background:var(--surface0); }
.panel-strip:hover::after { background:var(--accent); }
/* ── Main area ────────────────────────────────────────────────── */
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
/* Layout toggle button in topbar */
#btn-layout { display:flex; align-items:center; gap:4px; font-size:11px; padding:3px 8px; }
#btn-layout svg { flex-shrink:0; }
#btn-layout span { flex-shrink:0; }
.layout-toggle { font-size:11px; }
/* Layout dropdown menu */
#layout-menu {
position:fixed; z-index:200;
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
box-shadow:0 6px 20px rgba(0,0,0,0.5);
display:none; grid-template-columns:1fr 1fr; gap:4px; padding:6px;
}
#layout-menu.open { display:grid; }
.layout-menu-item {
display:flex; flex-direction:column; align-items:center; gap:3px;
padding:5px 10px; cursor:pointer;
background:var(--surface0); border:1px solid var(--surface1); border-radius:6px;
color:var(--subtext1); font-size:10px; font-family:monospace;
transition:background var(--transition),border-color var(--transition),color var(--transition);
}
.layout-menu-item:hover { background:var(--surface1); border-color:var(--accent); color:var(--accent); }
.layout-menu-item.active { background:var(--surface1); border-color:var(--accent); color:var(--accent); }
/* ── Plot grid ────────────────────────────────────────────────── */
#plot-grid {
flex:1; min-height:0; display:grid; gap:0; padding:0; overflow:hidden;
border-top:1px solid var(--surface0); border-left:1px solid var(--surface0);
position:relative;
}
.resize-handle-v {
position:absolute; top:0; bottom:0; width:6px; cursor:col-resize; z-index:20;
transform:translateX(-50%); background:transparent; transition:background 0.15s;
}
.resize-handle-h {
position:absolute; left:0; right:0; height:6px; cursor:row-resize; z-index:20;
transform:translateY(-50%); background:transparent; transition:background 0.15s;
}
.resize-handle-v:hover,.resize-handle-v.dragging,
.resize-handle-h:hover,.resize-handle-h.dragging {
background:rgba(137,180,250,0.35);
}
#plot-grid.l1x1 { grid-template-columns:1fr; grid-template-rows:1fr; }
#plot-grid.l2x1 { grid-template-columns:1fr 1fr; grid-template-rows:1fr; }
#plot-grid.l1x2 { grid-template-columns:1fr; grid-template-rows:1fr 1fr; }
#plot-grid.l2x2 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; }
#plot-grid.l3x1 { grid-template-columns:1fr 1fr 1fr; grid-template-rows:1fr; }
#plot-grid.l1x3 { grid-template-columns:1fr; grid-template-rows:1fr 1fr 1fr; }
#plot-grid.l3x2 { grid-template-columns:1fr 1fr 1fr; grid-template-rows:1fr 1fr; }
#plot-grid.l2x3 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr 1fr; }
#plot-grid.l1x4 { grid-template-columns:1fr; grid-template-rows:1fr 1fr 1fr 1fr; }
#plot-grid.l4x1 { grid-template-columns:1fr 1fr 1fr 1fr; grid-template-rows:1fr; }
/* ── Plot card ────────────────────────────────────────────────── */
.plot-card {
background:var(--bg);
border-right:1px solid var(--surface0); border-bottom:1px solid var(--surface0);
border-radius:0; display:flex; flex-direction:column;
min-height:0; position:relative; overflow:hidden;
}
.plot-card.drag-over { background:rgba(137,180,250,0.04); box-shadow:inset 0 0 0 2px var(--accent); }
.plot-card-header {
z-index:5; flex-shrink:0;
background:rgba(17,17,27,0.88); backdrop-filter:blur(6px);
border-bottom:1px solid var(--surface1);
display:flex; align-items:center; gap:5px;
padding:3px 8px; min-height:26px; overflow:hidden;
}
.plot-title {
font-size:11px; color:var(--subtext1); font-weight:600;
cursor:pointer; border:1px solid transparent; border-radius:3px;
padding:1px 4px; background:transparent; white-space:nowrap; user-select:none;
flex-shrink:0; max-width:100px; overflow:hidden; text-overflow:ellipsis;
transition:border-color 0.15s, background 0.15s;
}
.plot-title:hover { border-color:var(--surface1); background:rgba(88,91,112,0.4); }
.plot-cfg-bar {
flex-shrink:0; background:rgba(17,17,27,0.92); border-top:1px solid var(--surface1);
padding:3px 8px;
}
.sig-badges { display:flex; flex-wrap:nowrap; gap:3px; flex:1; overflow:hidden; min-width:0; }
.sig-badge {
display:inline-flex; align-items:center; gap:3px;
background:rgba(69,71,90,0.6); color:var(--subtext1);
border-radius:10px; padding:1px 6px 1px 4px;
font-size:10px; white-space:nowrap; flex-shrink:0; cursor:pointer;
}
.trace-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; display:inline-block; }
.sig-badge-x {
cursor:pointer; color:var(--overlay0); font-size:11px; line-height:1; margin-left:1px;
transition:color var(--transition);
}
.sig-badge-x:hover { color:var(--red); }
.sig-badge-active { outline:1px solid rgba(255,255,255,0.35); background:rgba(88,91,112,0.9); }
.sig-badge-active .vscale-info { color:var(--subtext0); }
.plot-body { flex:1; position:relative; min-height:0; overflow:hidden; }
.drop-hint {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:var(--overlay0); font-size:13px; pointer-events:none;
}
.trig-collect-overlay {
position:absolute; inset:0; display:none;
align-items:center; justify-content:center; pointer-events:none; z-index:10;
}
.plot-card.trig-collecting .trig-collect-overlay { display:flex; }
.trig-collect-text {
background:rgba(49,50,68,0.82); color:var(--mauve);
font-size:11px; font-weight:600; padding:4px 12px; border-radius:20px;
border:1px solid var(--mauve);
}
/* ── Signal style context menu ────────────────────────────────── */
#sig-ctx-menu {
position:fixed; z-index:300;
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; min-width:210px;
}
.ctx-menu-header {
font-size:11px; color:var(--subtext0); margin-bottom:8px;
padding-bottom:6px; border-bottom:1px solid var(--surface0);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.ctx-menu-key { color:var(--accent); font-weight:600; }
.ctx-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
.ctx-row label { font-size:11px; color:var(--subtext0); width:42px; flex-shrink:0; }
.ctx-btns { display:flex; gap:3px; flex-wrap:wrap; }
.ctx-btn {
background:var(--surface0); border:1px solid var(--surface1); border-radius:4px;
color:var(--subtext1); font-size:11px; padding:2px 7px; cursor:pointer;
transition:background var(--transition),border-color var(--transition),color var(--transition);
}
.ctx-btn:hover { background:var(--surface1); border-color:var(--accent); }
.ctx-btn.active { background:var(--surface1); border-color:var(--accent); color:var(--accent); }
#ctx-color {
width:28px; height:22px; border:1px solid var(--surface1); border-radius:4px;
background:transparent; cursor:pointer; padding:1px;
}
.ctx-range { width:80px; }
.ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; }
.ctx-num {
width:90px; background:var(--surface0); border:1px solid var(--surface1); border-radius:4px;
color:var(--text); font-size:11px; padding:2px 6px;
}
.ctx-num:focus { outline:none; border-color:var(--accent); }
.ctx-btn:disabled { opacity:0.35; cursor:not-allowed; border-color:var(--surface1); }
/* ── Array index picker ─────────────────────────────────────────── */
#array-idx-picker {
position:fixed; z-index:300;
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; min-width:200px;
}
/* ── VScale toolbar (embedded in plot card) ──────────────────────── */
#vscale-menu {
flex-shrink:0; background:rgba(17,17,27,0.92); border-top:1px solid var(--surface1);
padding:3px 8px;
}
.vstb-header {
display:flex; align-items:center; gap:8px; flex-wrap:nowrap; overflow-x:auto;
scrollbar-width:none;
}
.vstb-header::-webkit-scrollbar { display:none; }
.vstb-label { font-size:11px; color:var(--subtext0); white-space:nowrap; flex-shrink:0; }
.vstb-lbl { font-size:10px; color:var(--overlay0); white-space:nowrap; }
.vstb-close {
margin-left:auto; flex-shrink:0;
background:transparent; border:none; color:var(--overlay0);
cursor:pointer; font-size:11px; padding:0 3px; line-height:1;
transition:color var(--transition);
}
.vstb-close:hover { color:var(--red); }
.plot-vscale-bar { display:none; }
/* ── Per-plot cursor value readout ────────────────────────────── */
.plot-cursor-ro {
margin-left:auto; flex-shrink:0;
align-items:center; gap:5px;
font-size:10px; font-family:monospace;
background:var(--surface0); border:1px solid var(--surface1);
border-radius:4px; padding:1px 7px; white-space:nowrap; overflow:hidden;
}
.pcur-a { color:var(--sky); }
.pcur-b { color:var(--yellow); }
.pcur-dv { color:var(--subtext1); }
.pcur-sep { color:var(--surface2); }
/* ── Badge vscale info & active state ───────────────────────────── */
.vscale-info {
font-size:9px; color:var(--overlay0); font-family:monospace; margin-left:2px; white-space:nowrap;
}
/* ── Source groups ────────────────────────────────────────────── */
.source-group { margin-bottom:2px; }
.source-group-header {
display:flex; align-items:center; gap:5px;
padding:5px 8px 5px 10px; margin:4px 6px 2px;
background:var(--surface0); border-radius:6px;
}
.source-state-dot {
width:7px; height:7px; border-radius:50%; flex-shrink:0;
background:var(--overlay0); transition:background var(--transition);
}
.source-state-dot.connected { background:var(--green); }
.source-state-dot.connecting { background:var(--yellow); animation:pulse-orange 1.5s infinite; }
.source-state-dot.disconnected { background:var(--red); }
.source-name {
font-size:11px; font-weight:600; color:var(--subtext1); flex:1;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.source-addr {
font-size:10px; color:var(--overlay0); font-family:monospace;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:90px;
}
.source-remove-btn {
background:none; border:none; color:var(--overlay0);
cursor:pointer; font-size:15px; line-height:1; padding:0 2px; flex-shrink:0;
transition:color var(--transition);
}
.source-remove-btn:hover { color:var(--red); }
/* ── Add source section ───────────────────────────────────────── */
.add-source-section { border-top:1px solid var(--surface0); margin-top:4px; }
.add-source-title {
display:flex; align-items:center; gap:5px;
padding:6px 10px; cursor:pointer; user-select:none;
font-size:11px; color:var(--overlay0);
transition:color var(--transition);
}
.add-source-title:hover { color:var(--subtext1); }
.add-src-arrow { font-size:9px; display:inline-block; transition:transform var(--transition); }
.add-source-body { display:none; flex-direction:column; gap:5px; padding:0 10px 10px; }
.add-source-section.open .add-source-body { display:flex; }
.add-src-input {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:5px;
padding:4px 8px; font-size:12px; outline:none; width:100%;
}
.add-src-input:focus { border-color:var(--accent); }
.add-src-btn {
background:var(--surface0); color:var(--accent);
border:1px solid var(--surface1); border-radius:5px;
padding:4px 10px; font-size:12px; cursor:pointer; width:100%;
transition:background var(--transition),border-color var(--transition);
}
.add-src-btn:hover { background:rgba(137,180,250,0.15); border-color:var(--accent); }
.save-src-btn { color:var(--green); }
.save-src-btn:hover { background:rgba(166,227,161,0.1); border-color:var(--green); }
/* ── Right panel (debug: Traced/Forced/Breaks/Msgs) ────────── */
#right-panel {
width:260px; min-width:180px; max-width:420px;
display:flex; flex-direction:column;
border-left:1px solid var(--surface0); overflow:hidden;
background:var(--mantle);
}
#right-panel.collapsed { width:0; min-width:0; border:none; overflow:hidden; }
.tabs { display:flex; border-bottom:1px solid var(--surface0); flex-shrink:0; }
.tab {
flex:1; padding:4px; text-align:center; cursor:pointer;
color:var(--overlay0); font-size:11px;
border-bottom:2px solid transparent;
transition:color var(--transition),border-color var(--transition);
}
.tab:hover { color:var(--subtext1); }
.tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; flex:1; overflow-y:auto; flex-direction:column; }
.tab-content.active { display:flex; }
/* ── Traced / Forced signal rows ─────────────────────────── */
.traced-row {
display:flex; align-items:center; gap:4px;
padding:3px 6px; border-bottom:1px solid var(--crust); font-size:11px;
}
.traced-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--accent); }
.traced-val { font-family:monospace; color:var(--green); font-size:11px; min-width:60px; text-align:right; }
.forced-row { display:flex; align-items:center; gap:4px; padding:3px 6px; border-bottom:1px solid var(--crust); font-size:11px; }
.forced-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--peach); }
.forced-val { font-family:monospace; color:var(--yellow); font-size:11px; }
.break-item { display:flex; align-items:center; gap:4px; padding:3px 6px; border-bottom:1px solid var(--crust); font-size:11px; }
.break-sig { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--mauve); }
.msg-item { padding:3px 6px; border-bottom:1px solid var(--crust); font-size:11px; }
.empty-hint { padding:16px; color:var(--overlay0); text-align:center; font-size:12px; }
/* ── Panel collapse toggle ────────────────────────────────── */
.panel-toggle {
background:transparent; border:none; color:var(--subtext0);
padding:0 4px; font-size:11px; cursor:pointer; flex-shrink:0;
transition:color var(--transition);
}
.panel-toggle:hover { color:var(--accent); }
/* ── Log panel ────────────────────────────────────────────── */
#log-panel {
height:140px; min-height:26px; display:flex; flex-direction:column;
border-top:1px solid var(--surface0); background:var(--crust);
flex-shrink:0; overflow:hidden;
transition:height var(--transition);
}
#log-panel.collapsed { height:26px; }
#log-panel.collapsed #log-body { display:none; }
#log-toolbar {
display:flex; align-items:center; gap:8px;
padding:3px 8px; background:var(--mantle);
border-bottom:1px solid var(--surface0); flex-shrink:0; height:26px;
}
#log-toolbar label { display:flex; align-items:center; gap:3px; font-size:11px; color:var(--subtext0); cursor:pointer; }
#log-toolbar input[type=text] { font-size:11px; background:var(--surface0); border:1px solid var(--surface1); border-radius:3px; color:var(--text); padding:1px 4px; }
#log-body { flex:1; overflow-y:auto; font-family:monospace; font-size:11px; padding:2px 0; }
.log-line { padding:1px 8px; display:flex; gap:8px; }
.log-time { color:var(--overlay0); flex-shrink:0; font-family:monospace; }
.log-lvl { flex-shrink:0; min-width:50px; font-weight:600; }
.log-msg { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
.log-line.DEBUG .log-lvl { color:var(--accent); }
.log-line.INFO .log-lvl { color:var(--green); }
.log-line.WARNING .log-lvl { color:var(--peach); }
.log-line.ERROR .log-lvl { color:var(--red); }
.log-line.CMD .log-lvl { color:var(--teal); }
.log-line.RESP .log-lvl { color:var(--sky); }
.log-hidden { display:none; }
/* ── Stats panel ─────────────────────────────────────────────── */
#stats-panel {
position:fixed; bottom:0; left:0; right:0;
height:0; overflow:hidden;
background:var(--mantle); border-top:2px solid var(--surface0);
z-index:89;
transition:height var(--transition);
}
#stats-panel.open { height:290px; }
#stats-panel-hdr {
display:flex; align-items:center; gap:8px;
padding:4px 10px; border-bottom:1px solid var(--surface0); flex-shrink:0;
font-size:11px; font-weight:700; color:var(--subtext1); letter-spacing:0.5px; text-transform:uppercase;
}
.stats-hdr-label { flex-shrink:0; }
.stats-source-sel {
flex:1; min-width:0;
background:var(--surface0); border:1px solid var(--surface1); border-radius:var(--radius);
color:var(--text); font-size:11px; padding:1px 5px; cursor:pointer;
}
.stats-source-sel:focus { outline:none; border-color:var(--accent); }
#btn-stats-close {
background:none; border:none; color:var(--overlay0); flex-shrink:0;
cursor:pointer; font-size:14px; line-height:1; padding:2px;
transition:color var(--transition);
}
#btn-stats-close:hover { color:var(--red); }
#stats-body {
overflow-x:hidden; overflow-y:auto;
display:flex; flex-direction:column; gap:0;
padding:8px 18px;
height:calc(290px - 30px); box-sizing:border-box;
}
.stats-section { display:flex; flex-direction:column; gap:5px; padding:4px 0; }
.stats-section-grow { flex:1; }
.stats-empty { font-size:11px; color:var(--overlay0); }
.stats-section-label { font-size:9px; color:var(--overlay0); text-transform:uppercase; letter-spacing:0.6px; }
.stats-row { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-end; }
.stats-kv { display:flex; flex-direction:column; gap:1px; min-width:70px; }
.stats-k { font-size:9px; color:var(--overlay0); text-transform:uppercase; letter-spacing:0.5px; }
.stats-v { font-size:12px; color:var(--text); font-family:monospace; font-weight:600; }
.stats-v.warn { color:var(--yellow); }
.stats-v.ok { color:var(--green); }
.stats-sep { border:none; border-top:1px solid var(--surface0); margin:2px 0; }
.stats-hist { width:100%; }
.hist-bars { display:flex; align-items:flex-end; gap:1px; height:72px; background:var(--crust); border-radius:3px; padding:2px 3px; }
.hist-bar { flex:1; background:var(--accent); border-radius:1px 1px 0 0; min-height:1px; opacity:0.65; transition:opacity 0.1s; }
.hist-bar:hover { opacity:1; }
.hist-labels { display:flex; justify-content:space-between; font-size:9px; color:var(--overlay0); font-family:monospace; margin-top:2px; }
/* ── Dialogs ──────────────────────────────────────────────── */
.dialog-overlay {
position:fixed; inset:0; background:rgba(0,0,0,.6);
display:flex; align-items:center; justify-content:center; z-index:200;
}
.dialog {
background:var(--mantle); border:1px solid var(--surface1);
border-radius:var(--radius); padding:16px; min-width:320px; max-width:500px;
}
.dialog h3 { margin-bottom:12px; color:var(--accent); }
.dialog label { display:block; margin-bottom:4px; color:var(--subtext0); font-size:13px; }
.dialog input, .dialog select, .dialog textarea {
width:100%; background:var(--surface0); border:1px solid var(--surface1);
color:var(--text); padding:4px 8px; border-radius:4px; margin-bottom:10px;
}
.dialog textarea { height:80px; resize:vertical; font-family:monospace; font-size:12px; }
.dialog .btns { display:flex; gap:8px; justify-content:flex-end; margin-top:4px; }
.dialog select option { background:var(--mantle); }
.form-row { display:flex; gap:8px; }
.form-row > * { flex:1; }
.form-check { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
.form-check input[type=checkbox] { width:auto; margin:0; }
.form-check label { margin:0; color:var(--subtext0); display:inline; }
/* ── Array selection segment ──────────────────────────────── */
.arr-seg {
display:flex; gap:0; margin-bottom:12px;
border-radius:4px; overflow:hidden; border:1px solid var(--surface1);
}
.arr-seg button {
flex:1; border:none; border-radius:0; border-right:1px solid var(--surface1);
color:var(--subtext0); background:var(--surface0); padding:4px 0; font-size:11px;
}
.arr-seg button:last-child { border-right:none; }
.arr-seg button.active { background:var(--accent); color:var(--crust); }
.arr-seg button:hover:not(.active) { background:var(--surface1); color:var(--text); }
/* ── Tree (Object Tree tab) ───────────────────────────────── */
.tree-node { padding:1px 0; }
.tree-leaf { display:flex; align-items:center; gap:4px; padding:2px 6px; cursor:default; user-select:none; font-size:11px; }
.tree-leaf:hover { background:var(--surface0); }
.tree-leaf.selected { background:var(--surface1); }
.tree-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tree-class { color:var(--overlay0); font-size:10px; flex-shrink:0; }
details > summary { list-style:none; cursor:pointer; padding:2px 6px; display:flex; align-items:center; gap:4px; user-select:none; font-size:11px; }
details > summary:hover { background:var(--surface0); }
details > summary::before { content:'▶'; font-size:9px; color:var(--overlay0); width:10px; flex-shrink:0; }
details[open] > summary::before { content:'▼'; }
details > .children { padding-left:14px; }
.tree-loading { padding:2px 4px; color:var(--overlay0); font-size:10px; font-style:italic; }
.tree-btn {
background:transparent; border:1px solid var(--surface1); color:var(--subtext0);
padding:0 4px; border-radius:3px; cursor:pointer; font-size:10px; line-height:14px;
}
.tree-btn:hover { background:var(--surface1); color:var(--text); }
.tree-btn.t { border-color:var(--accent); color:var(--accent); }
.tree-btn.f { border-color:var(--green); color:var(--green); }
.tree-btn.b { border-color:var(--peach); color:var(--peach); }
/* ── Empty state (center area hint) ─────────────────────── */
#empty-state {
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
text-align:center; color:var(--subtext0); pointer-events:none; display:none;
}
#empty-state.visible { display:block; }
#empty-state h2 { font-size:20px; margin-bottom:8px; color:var(--surface2); }
#empty-state p { font-size:13px; }
/* ── UDP stats ────────────────────────────────────────────── */
#udp-stats { color:var(--overlay0); font-size:11px; }
@media (max-width:700px) { #sidebar { width:0; min-width:0; } :root { --sidebar-w:240px; } }
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
+283
View File
@@ -0,0 +1,283 @@
'use strict';
/* ════════════════════════════════════════════════════════════════
Web Worker buffer management, binary parsing, LTTB
════════════════════════════════════════════════════════════════ */
const TEMPORAL_CAP = 600_000;
const DEFAULT_CAP = 10_000;
// Circular buffers: key → {t:Float64Array, v:Float64Array, head, size, cap}
const buffers = {};
function makeBuffer(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 frame parser ─────────────────────────────────────────────
// 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 parseBinaryFrame(buf) {
const dv = new DataView(buf);
let off = 0;
if (dv.getUint8(off) !== 1) { console.warn('[worker] bad binary version'); 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;
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) {
// Auto-create buffer with reasonable capacity
const cap = n > 100 ? TEMPORAL_CAP : DEFAULT_CAP;
bufObj = makeBuffer(cap);
buffers[fullKey] = bufObj;
}
// Read t values
for (let i = 0; i < n; i++) {
const t = dv.getFloat64(off, true); off += 8;
const v = dv.getFloat64(off + n * 8, true); // v array starts after t array
pushBuffer(bufObj, t, v);
}
off += n * 8; // skip v array (already read inline above)
}
}
// ─── Range slice from circular buffer ────────────────────────────────
function getBufferSliceRange(bufObj, t0, t1) {
const { cap, size, head } = bufObj;
if (size === 0) return { t: new Float64Array(0), v: new Float64Array(0) };
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 (bufObj.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 (bufObj.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(bufObj.t.subarray(physStart, physStart + len));
outV.set(bufObj.v.subarray(physStart, physStart + len));
} else {
outT.set(bufObj.t.subarray(physStart, physStart + tail));
outT.set(bufObj.t.subarray(0, len - tail), tail);
outV.set(bufObj.v.subarray(physStart, physStart + tail));
outV.set(bufObj.v.subarray(0, len - tail), tail);
}
return { t: outT, v: outV };
}
// ─── LTTB decimation ─────────────────────────────────────────────────
function lttb(t, v, threshold) {
const len = t.length;
if (len <= threshold || threshold < 3) 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 };
}
// ─── Linear resampling ───────────────────────────────────────────────
function resampleLinear(tSrc, vSrc, tDst) {
const n = tDst.length;
const out = new Float64Array(n);
if (tSrc.length === 0) return out;
if (tSrc.length === 1) { out.fill(vSrc[0]); return out; }
let j = 0;
for (let i = 0; i < n; i++) {
const td = tDst[i];
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;
}
// ─── Master time grid selection ──────────────────────────────────────
// samplingRates: key → rate (Hz), provided by main thread on init
const samplingRates = {};
function pickMasterKey(keys) {
let bestKey = keys[0], bestRate = -1;
for (const k of keys) {
const rate = samplingRates[k] || 0;
if (rate > bestRate) { bestRate = rate; bestKey = k; }
}
return bestKey;
}
// ─── Build uPlot-compatible data arrays ──────────────────────────────
function buildRenderData(keys, t0, t1, targetPts) {
if (!keys || keys.length === 0) return [new Float64Array(0)];
const slices = {};
let masterKey = pickMasterKey(keys), masterCount = -1;
for (const key of keys) {
const bufObj = buffers[key];
if (!bufObj || bufObj.size === 0) continue;
const sl = getBufferSliceRange(bufObj, t0, t1);
slices[key] = sl;
if (sl.t.length > masterCount) { masterCount = sl.t.length; masterKey = key; }
}
const masterRaw = slices[masterKey];
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...keys.map(() => new Float64Array(0))];
const dec = lttb(masterRaw.t, masterRaw.v, targetPts);
const sharedT = dec.t;
const yArrays = [];
for (const key of keys) {
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; }
yArrays.push(resampleLinear(sl.t, sl.v, sharedT));
}
const result = [sharedT, ...yArrays];
// Transfer ownership of the Float64Arrays to main thread
const transferList = result.map(a => a.buffer);
return { data: result, transfer: transferList };
}
// ─── Message handler ─────────────────────────────────────────────────
self.onmessage = function(e) {
const msg = e.data;
switch (msg.type) {
case 'initSignals': {
// {signals: [{key, cap}]}
const sigs = msg.signals || [];
sigs.forEach(s => {
if (!buffers[s.key]) {
buffers[s.key] = makeBuffer(s.cap || DEFAULT_CAP);
}
if (s.samplingRate !== undefined) {
samplingRates[s.key] = s.samplingRate;
}
});
break;
}
case 'binaryData': {
// {buffer: ArrayBuffer} — transferred from main thread
parseBinaryFrame(msg.buffer);
self.postMessage({ type: 'dataReady' });
break;
}
case 'requestData': {
// {id, t0, t1, targetPts, keys}
const { id, t0, t1, targetPts, keys } = msg;
const { data, transfer } = buildRenderData(keys, t0, t1, targetPts);
self.postMessage({ type: 'renderData', id, data }, transfer);
break;
}
case 'clearSource': {
const prefix = msg.prefix;
Object.keys(buffers).forEach(k => {
if (k.startsWith(prefix)) delete buffers[k];
});
Object.keys(samplingRates).forEach(k => {
if (k.startsWith(prefix)) delete samplingRates[k];
});
break;
}
case 'getBufferNow': {
// Returns newest timestamp across given keys
const keys = msg.keys || [];
let latest = -Infinity;
keys.forEach(key => {
const bufObj = buffers[key];
if (bufObj && bufObj.size > 0) {
const t = bufObj.t[(bufObj.head - 1 + bufObj.cap) % bufObj.cap];
if (t > latest) latest = t;
}
});
self.postMessage({ type: 'bufferNow', id: msg.id, now: isFinite(latest) ? latest : null });
break;
}
case 'getBufferForTrig': {
// Returns full buffer contents for a single key (used for trigger check)
const key = msg.key;
const bufObj = buffers[key];
if (!bufObj || bufObj.size === 0) {
self.postMessage({ type: 'trigBuf', id: msg.id, key, size: 0 });
break;
}
// Copy out all data
const { cap, size, head } = bufObj;
const start = (size === cap) ? head : 0;
const t = new Float64Array(size), v = new Float64Array(size);
const physAt = k => (start + k) % cap;
for (let i = 0; i < size; i++) {
const p = physAt(i);
t[i] = bufObj.t[p];
v[i] = bufObj.v[p];
}
self.postMessage({
type: 'trigBuf', id: msg.id, key, size,
t, v
}, [t.buffer, v.buffer]);
break;
}
}
};