Initial release
This commit is contained in:
@@ -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
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>>=</option>
|
||||
<option><</option><option><=</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 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>
|
||||
· <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 <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><</option><option><=</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>
|
||||
· <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>
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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; } }
|
||||
+2
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -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;}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user