From 0a5a85e4c4df1bf5b3b469e1b687ac92240f1c4b Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 4 May 2026 21:13:36 +0200 Subject: [PATCH] working epics ioc and tested --- internal/api/api.go | 44 +++ internal/datasource/epics/epics.go | 8 +- internal/datasource/synthetic/synthetic.go | 46 +++ internal/dsp/nodes.go | 183 +++++++++-- internal/server/ws.go | 16 +- pkg/ca/client.go | 21 +- pkg/ca/conn.go | 61 +++- web/src/App.tsx | 17 +- web/src/Canvas.tsx | 26 +- web/src/ContextMenu.tsx | 21 +- web/src/EditMode.tsx | 4 +- web/src/InfoPanel.tsx | 121 +++++++ web/src/PlotPanel.tsx | 278 ++++++++++++++++ web/src/PropertiesPane.tsx | 42 ++- web/src/SignalTree.tsx | 18 + web/src/SyntheticEditor.tsx | 194 +++++++++++ web/src/SyntheticWizard.tsx | 18 +- web/src/ViewMode.tsx | 67 +++- web/src/lib/format.ts | 44 +++ web/src/styles.css | 364 +++++++++++++++++++++ web/src/widgets/BarH.tsx | 4 +- web/src/widgets/BarV.tsx | 4 +- web/src/widgets/Gauge.tsx | 4 +- web/src/widgets/PlotWidget.tsx | 75 +++-- web/src/widgets/TextView.tsx | 6 +- 25 files changed, 1550 insertions(+), 136 deletions(-) create mode 100644 web/src/InfoPanel.tsx create mode 100644 web/src/PlotPanel.tsx create mode 100644 web/src/SyntheticEditor.tsx create mode 100644 web/src/lib/format.ts diff --git a/internal/api/api.go b/internal/api/api.go index 8a0dc68..5fcc7fd 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -45,6 +45,8 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { // Synthetic signal CRUD mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic) mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic) + mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic) + mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic) mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) } @@ -307,6 +309,48 @@ func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) { jsonOK(w, def) } +func (h *Handler) getSynthetic(w http.ResponseWriter, r *http.Request) { + if h.synthetic == nil { + jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") + return + } + name := r.PathValue("name") + def, err := h.synthetic.GetSignal(name) + if err != nil { + if errors.Is(err, datasource.ErrNotFound) { + jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) + } else { + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + jsonOK(w, def) +} + +func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) { + if h.synthetic == nil { + jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") + return + } + name := r.PathValue("name") + var def synthetic.SignalDef + if err := json.NewDecoder(r.Body).Decode(&def); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + // Ensure the name in the URL matches the body (or fill it in). + def.Name = name + if err := h.synthetic.UpdateSignal(def); err != nil { + if errors.Is(err, datasource.ErrNotFound) { + jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) + } else { + jsonError(w, http.StatusBadRequest, err.Error()) + } + return + } + jsonOK(w, def) +} + func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) { if h.synthetic == nil { jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go index fc0a005..6ebe19a 100644 --- a/internal/datasource/epics/epics.go +++ b/internal/datasource/epics/epics.go @@ -342,10 +342,10 @@ func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Meta // fetchMetadata retrieves metadata from a connected chid using ca_get. func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata { - // In EPICS, write access is governed by CA security (host/user rules), not - // by the field type. Default to writable=true; the IOC will reject puts - // that violate its security policy. - meta := datasource.Metadata{Name: name, Writable: true} + // ca_write_access returns 1 if CA security permits puts from this client. + // This reflects the IOC's actual security policy (host/user ACLs), so we + // use it directly rather than defaulting to true and relying on put failures. + meta := datasource.Metadata{Name: name, Writable: C.ca_write_access(chid) != 0} fieldType := C.ca_field_type(chid) count := C.ca_element_count(chid) diff --git a/internal/datasource/synthetic/synthetic.go b/internal/datasource/synthetic/synthetic.go index 485e014..0c0f77e 100644 --- a/internal/datasource/synthetic/synthetic.go +++ b/internal/datasource/synthetic/synthetic.go @@ -286,6 +286,52 @@ func (s *Synthetic) RemoveSignal(name string) error { return nil } +// GetSignal returns the definition of a single named signal. +func (s *Synthetic) GetSignal(name string) (SignalDef, error) { + s.mu.RLock() + defer s.mu.RUnlock() + st, ok := s.signals[name] + if !ok { + return SignalDef{}, datasource.ErrNotFound + } + return st.def, nil +} + +// UpdateSignal replaces the pipeline of an existing synthetic signal at runtime +// and persists the change. The signal must already exist. +func (s *Synthetic) UpdateSignal(def SignalDef) error { + if def.Name == "" { + return errors.New("signal name must not be empty") + } + + nodes, err := BuildPipeline(def.Pipeline) + if err != nil { + return fmt.Errorf("build pipeline: %w", err) + } + + states := make([]map[string]any, len(nodes)) + for i := range states { + states[i] = make(map[string]any) + } + + s.mu.Lock() + old, exists := s.signals[def.Name] + if !exists { + s.mu.Unlock() + return datasource.ErrNotFound + } + if old.cancel != nil { + old.cancel() + } + s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states} + s.mu.Unlock() + + if err := s.saveDefs(); err != nil { + return fmt.Errorf("persist definitions: %w", err) + } + return nil +} + // GetDefs returns a copy of all current signal definitions (for the REST API). func (s *Synthetic) GetDefs() []SignalDef { s.mu.RLock() diff --git a/internal/dsp/nodes.go b/internal/dsp/nodes.go index f211504..d977911 100644 --- a/internal/dsp/nodes.go +++ b/internal/dsp/nodes.go @@ -277,12 +277,21 @@ func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) return result, nil } -// exprParser is a recursive-descent parser for simple arithmetic expressions. +// exprParser is a recursive-descent parser for arithmetic expressions. // Grammar: // -// expr = term (('+' | '-') term)* -// term = factor (('*' | '/') factor)* +// expr = term (('+' | '-') term)* +// term = power (('*' | '/') power)* +// power = unary ('^' power)* — right-associative exponentiation +// unary = '-' unary | call +// call = ident '(' expr ')' | factor // factor = '(' expr ')' | number | variable +// +// Supported functions: exp, log, log2, log10, sqrt, abs, sin, cos, tan, +// +// asin, acos, atan, floor, ceil, round, pow +// +// Variables: a, b, c, d (bound to inputs[0..3]). type exprParser struct { src string pos int @@ -328,7 +337,7 @@ func (p *exprParser) parseExpr() (float64, error) { } func (p *exprParser) parseTerm() (float64, error) { - left, err := p.parseFactor() + left, err := p.parsePower() if err != nil { return 0, err } @@ -338,7 +347,7 @@ func (p *exprParser) parseTerm() (float64, error) { break } p.pos++ - right, err := p.parseFactor() + right, err := p.parsePower() if err != nil { return 0, err } @@ -354,6 +363,136 @@ func (p *exprParser) parseTerm() (float64, error) { return left, nil } +// parsePower handles right-associative exponentiation: a^b^c = a^(b^c). +// Unary minus is consumed here so that -a^2 = -(a^2), not (-a)^2 — matching +// standard mathematical and Python/Julia precedence rules. +func (p *exprParser) parsePower() (float64, error) { + // Collect leading unary minuses; an even count cancels out. + sign := 1.0 + p.skipSpaces() + for p.pos < len(p.src) && p.src[p.pos] == '-' { + p.pos++ + sign = -sign + p.skipSpaces() + } + + base, err := p.parseCall() + if err != nil { + return 0, err + } + + p.skipSpaces() + if p.pos < len(p.src) && p.src[p.pos] == '^' { + p.pos++ + exp, err := p.parsePower() // right-associative recursion + if err != nil { + return 0, err + } + return sign * math.Pow(base, exp), nil + } + return sign * base, nil +} + +// parseCall handles function calls like exp(a) or pow(a, 2). +func (p *exprParser) parseCall() (float64, error) { + p.skipSpaces() + if p.pos >= len(p.src) { + return 0, errors.New("unexpected end of expression") + } + + // Try to read an identifier (function name or variable). + if p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' { + start := p.pos + for p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || + p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' || + p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || + p.src[p.pos] == '_') { + p.pos++ + } + name := p.src[start:p.pos] + + // Check for function call syntax: name(...) + p.skipSpaces() + if p.pos < len(p.src) && p.src[p.pos] == '(' { + p.pos++ // consume '(' + arg1, err := p.parseExpr() + if err != nil { + return 0, err + } + // Optional second argument for two-argument functions. + var arg2 float64 + p.skipSpaces() + if p.pos < len(p.src) && p.src[p.pos] == ',' { + p.pos++ + arg2, err = p.parseExpr() + if err != nil { + return 0, err + } + p.skipSpaces() + } + if p.pos >= len(p.src) || p.src[p.pos] != ')' { + return 0, fmt.Errorf("missing ')' after %s(...)", name) + } + p.pos++ // consume ')' + + switch name { + case "exp": + return math.Exp(arg1), nil + case "log", "ln": + return math.Log(arg1), nil + case "log2": + return math.Log2(arg1), nil + case "log10": + return math.Log10(arg1), nil + case "sqrt": + return math.Sqrt(arg1), nil + case "abs": + return math.Abs(arg1), nil + case "sin": + return math.Sin(arg1), nil + case "cos": + return math.Cos(arg1), nil + case "tan": + return math.Tan(arg1), nil + case "asin": + return math.Asin(arg1), nil + case "acos": + return math.Acos(arg1), nil + case "atan": + return math.Atan(arg1), nil + case "atan2": + return math.Atan2(arg1, arg2), nil + case "pow": + return math.Pow(arg1, arg2), nil + case "floor": + return math.Floor(arg1), nil + case "ceil": + return math.Ceil(arg1), nil + case "round": + return math.Round(arg1), nil + case "min": + return math.Min(arg1, arg2), nil + case "max": + return math.Max(arg1, arg2), nil + default: + return 0, fmt.Errorf("unknown function %q", name) + } + } + + // Not a function call — must be a single-letter variable. + if len(name) != 1 { + return 0, fmt.Errorf("unknown identifier %q (use a–d for variables, or a known function name)", name) + } + val, ok := p.vars[name] + if !ok { + return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name) + } + return val, nil + } + + return p.parseFactor() +} + func (p *exprParser) parseFactor() (float64, error) { p.skipSpaces() if p.pos >= len(p.src) { @@ -362,7 +501,7 @@ func (p *exprParser) parseFactor() (float64, error) { ch := p.src[p.pos] - // Parenthesised subexpression + // Parenthesised subexpression. if ch == '(' { p.pos++ val, err := p.parseExpr() @@ -377,35 +516,13 @@ func (p *exprParser) parseFactor() (float64, error) { return val, nil } - // Unary minus - if ch == '-' { - p.pos++ - val, err := p.parseFactor() - if err != nil { - return 0, err - } - return -val, nil - } - - // Variable (a, b, c, d only) - if ch >= 'a' && ch <= 'z' { - name := string(ch) - p.pos++ - // Make sure we only accept single-letter variables - if p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' || p.src[p.pos] == '_') { - return 0, fmt.Errorf("unknown identifier starting with %q", name) - } - val, ok := p.vars[name] - if !ok { - return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name) - } - return val, nil - } - - // Number (including optional decimal point and exponent) + // Number (including optional decimal point and exponent notation). if ch >= '0' && ch <= '9' || ch == '.' { start := p.pos - for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' || ((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start && (p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) { + for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || + p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' || + ((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start && + (p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) { p.pos++ } f, err := strconv.ParseFloat(p.src[start:p.pos], 64) diff --git a/internal/server/ws.go b/internal/server/ws.go index 51a0ea0..7fc7290 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -115,7 +115,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { wg.Add(3) go func() { defer wg.Done(); c.readLoop(ctx, cancel) }() go func() { defer wg.Done(); c.dispatchLoop(ctx) }() - go func() { defer wg.Done(); c.writeLoop(ctx) }() + go func() { defer wg.Done(); c.writeLoop(ctx, cancel) }() wg.Wait() // Cancel all subscriptions on disconnect. @@ -193,7 +193,10 @@ func (c *wsClient) dispatchLoop(ctx context.Context) { } // writeLoop sends queued messages over the WebSocket connection. -func (c *wsClient) writeLoop(ctx context.Context) { +// It cancels ctx on any write error so that readLoop and dispatchLoop exit +// promptly rather than blocking forever waiting for outCh to drain. +func (c *wsClient) writeLoop(ctx context.Context, cancel context.CancelFunc) { + defer cancel() for { select { case msg := <-c.outCh: @@ -251,8 +254,11 @@ func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) { c.subs[ref] = cancelSub c.subsMu.Unlock() - // Send metadata once on subscribe. - c.sendMeta(ctx, ref) + // Send metadata in a goroutine: for EPICS signals GetMetadata may block + // for several seconds waiting for the CA channel to connect and for + // ca_pend_io to complete. Running it inline would stall readLoop and + // prevent any further messages from this client from being processed. + go c.sendMeta(ctx, ref) } } @@ -341,7 +347,7 @@ func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) { b, _ := json.Marshal(resp) select { case c.outCh <- b: - default: + case <-ctx.Done(): } } diff --git a/pkg/ca/client.go b/pkg/ca/client.go index 8678803..77094ad 100644 --- a/pkg/ca/client.go +++ b/pkg/ca/client.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "os" - "path/filepath" + "os/user" "strings" "sync" @@ -42,11 +42,18 @@ type Config struct { // EPICS_CA_ADDR_LIST — space-separated list of server addresses // EPICS_CA_AUTO_ADDR_LIST — "NO" disables automatic broadcast addresses func ConfigFromEnv() Config { - name := filepath.Base(os.Args[0]) + // CA security ACLs match on the client username, not the program name. + // Use the OS username (same as libca), falling back to $USER, then "user". + clientName := "user" + if u, err := user.Current(); err == nil && u.Username != "" { + clientName = u.Username + } else if v := os.Getenv("USER"); v != "" { + clientName = v + } host, _ := os.Hostname() cfg := Config{ AutoAddrList: true, - ClientName: name, + ClientName: clientName, HostName: host, } if v := os.Getenv("EPICS_CA_ADDR_LIST"); v != "" { @@ -103,7 +110,13 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { } if cfg.ClientName == "" { - cfg.ClientName = filepath.Base(os.Args[0]) + if u, err := user.Current(); err == nil && u.Username != "" { + cfg.ClientName = u.Username + } else if v := os.Getenv("USER"); v != "" { + cfg.ClientName = v + } else { + cfg.ClientName = "user" + } } if cfg.HostName == "" { cfg.HostName, _ = os.Hostname() diff --git a/pkg/ca/conn.go b/pkg/ca/conn.go index 869f3ae..d9f5c75 100644 --- a/pkg/ca/conn.go +++ b/pkg/ca/conn.go @@ -57,13 +57,15 @@ type chanState struct { cid uint32 pvName string - mu sync.RWMutex - sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply) - dbfType int // native DBF field type - count uint32 // element count - access uint32 // AccessRead | AccessWrite bitmask - readyC chan struct{} // closed once CREATE_CHAN reply is received - monitors []*monState // active subscriptions + mu sync.RWMutex + sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply) + dbfType int // native DBF field type + count uint32 // element count + access uint32 // AccessRead | AccessWrite bitmask + gotChan bool // CREATE_CHAN reply received for current connection + gotAccess bool // ACCESS_RIGHTS received for current connection + readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect + monitors []*monState // active subscriptions } func newChanState(cid uint32, pvName string) *chanState { @@ -82,6 +84,8 @@ func newChanState(cid uint32, pvName string) *chanState { func (cs *chanState) resetForReconnect() { cs.mu.Lock() cs.sid = 0 + cs.gotChan = false + cs.gotAccess = false old := cs.readyC cs.readyC = make(chan struct{}) cs.mu.Unlock() @@ -94,25 +98,25 @@ func (cs *chanState) resetForReconnect() { } } -// waitReady blocks until sid != 0 (CREATE_CHAN reply received for the current -// connection) or ctx expires. It loops through reconnections automatically: -// when resetForReconnect closes the current readyC the goroutine wakes, sees -// sid == 0, and waits on the freshly created channel. +// waitReady blocks until both CREATE_CHAN and ACCESS_RIGHTS have been received +// (i.e. sid != 0 and gotAccess == true) or ctx expires. It loops through +// reconnections automatically: when resetForReconnect closes the current readyC +// the goroutine wakes, sees sid == 0, and waits on the freshly created channel. func (cs *chanState) waitReady(ctx context.Context) error { for { cs.mu.RLock() sid := cs.sid + gotAccess := cs.gotAccess ready := cs.readyC cs.mu.RUnlock() - if sid != 0 { + if sid != 0 && gotAccess { return nil } select { case <-ready: - // State changed — either CREATE_CHAN reply (sid set) or reconnect - // started (sid cleared, new readyC installed). Loop to check. + // State changed — loop to re-check sid and gotAccess. case <-ctx.Done(): return ctx.Err() } @@ -378,7 +382,11 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) { cs.sid = hdr.Parameter2 cs.dbfType = int(hdr.DataType) cs.count = hdr.DataCount - ready := cs.readyC + cs.gotChan = true + var ready chan struct{} + if cs.gotAccess { + ready = cs.readyC + } // Snapshot monitors for EVENT_ADD re-subscription. mons := make([]*monState, len(cs.monitors)) copy(mons, cs.monitors) @@ -397,7 +405,12 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) { default: } } - close(ready) // unblock waitReady callers + // Unblock waitReady only once ACCESS_RIGHTS has also arrived. + // ACCESS_RIGHTS always follows CREATE_CHAN, so ready is typically nil here + // and the close happens in the CmdAccessRights handler below. + if ready != nil { + close(ready) + } case proto.CmdCreateFail: // Server does not host this PV (or quota exceeded). @@ -422,13 +435,29 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) { case proto.CmdAccessRights: // Parameter1 = cid, Parameter2 = access bitmask. + // ACCESS_RIGHTS always arrives just after CREATE_CHAN; we defer closing + // readyC until here so callers see the correct access value immediately. c.mu.RLock() cs, ok := c.byCID[hdr.Parameter1] c.mu.RUnlock() + dbg("CA ACCESS_RIGHTS", "cid", hdr.Parameter1, "access", hdr.Parameter2, "found", ok) if ok { cs.mu.Lock() cs.access = hdr.Parameter2 + cs.gotAccess = true + var ready chan struct{} + if cs.gotChan { + ready = cs.readyC + } cs.mu.Unlock() + if ready != nil { + select { + case <-ready: + // Already closed (guard against duplicate ACCESS_RIGHTS). + default: + close(ready) + } + } } case proto.CmdEventAdd: diff --git a/web/src/App.tsx b/web/src/App.tsx index 2863804..52222ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,8 @@ export default function App() { const [mode, setMode] = useState('view'); const [wsStatus, setWsStatus] = useState('connecting'); const [editTarget, setEditTarget] = useState(null); + // Interface to show when returning to view mode after editing + const [viewTarget, setViewTarget] = useState(null); useEffect(() => { const unsub = wsClient.status.subscribe(setWsStatus); @@ -36,7 +38,8 @@ export default function App() { setMode('edit'); } - function exitEdit() { + function exitEdit(iface: Interface) { + setViewTarget(iface); setMode('view'); setEditTarget(null); } @@ -46,10 +49,14 @@ export default function App() { {wsStatus === 'disconnected' && (
WebSocket disconnected — reconnecting…
)} - {mode === 'view' - ? - : - } + {/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed. + EditMode is conditionally rendered; it resets from props each time anyway. */} +
+ +
+ {mode === 'edit' && ( + + )} ); } diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index a6a8405..3baae30 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -14,6 +14,7 @@ import PlotWidget from './widgets/PlotWidget'; import ImageWidget from './widgets/ImageWidget'; import LinkWidget from './widgets/LinkWidget'; import ContextMenu from './ContextMenu'; +import InfoPanel from './InfoPanel'; const COMPONENTS: Record = { textview: TextView, @@ -41,12 +42,14 @@ interface Props { iface: Interface | null; onNavigate?: (interfaceId: string) => void; timeRange?: { start: string; end: string } | null; + onPlot?: (signal: SignalRef) => void; } -export default function Canvas({ iface, onNavigate, timeRange }: Props) { +export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) { const [ctxMenu, setCtxMenu] = useState({ visible: false, x: 0, y: 0, signal: null, }); + const [infoSignal, setInfoSignal] = useState(null); function onCtxMenu(e: MouseEvent, widget: Widget) { e.preventDefault(); @@ -54,6 +57,18 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) { setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null }); } + function closeCtxMenu() { + setCtxMenu(c => ({ ...c, visible: false })); + } + + function handleInfo() { + if (ctxMenu.signal) setInfoSignal(ctxMenu.signal); + } + + function handlePlot() { + if (ctxMenu.signal && onPlot) onPlot(ctxMenu.signal); + } + if (!iface) { return (
@@ -90,10 +105,17 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) { ); })}
+ setCtxMenu(c => ({ ...c, visible: false }))} + onClose={closeCtxMenu} + onInfo={handleInfo} + onPlot={onPlot ? handlePlot : undefined} /> + + {infoSignal && ( + setInfoSignal(null)} /> + )} ); } diff --git a/web/src/ContextMenu.tsx b/web/src/ContextMenu.tsx index decd45d..5f7a036 100644 --- a/web/src/ContextMenu.tsx +++ b/web/src/ContextMenu.tsx @@ -10,9 +10,11 @@ interface Props { y: number; signal: SignalRef | null; onClose: () => void; + onInfo?: () => void; + onPlot?: () => void; } -export default function ContextMenu({ visible, x, y, signal, onClose }: Props) { +export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, onPlot }: Props) { const [meta, setMeta] = useState(null); const menuRef = useRef(null); @@ -36,12 +38,20 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) { if (!visible) return null; function copySignalName() { - if (signal) { - navigator.clipboard.writeText(signal.name).catch(() => {}); - } + if (signal) navigator.clipboard.writeText(signal.name).catch(() => {}); onClose(); } + function openInfo() { + onClose(); + onInfo?.(); + } + + function openPlot() { + onClose(); + onPlot?.(); + } + function exportCSV() { // Phase 5+: export CSV data onClose(); @@ -62,6 +72,9 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) { )}
+ + +
diff --git a/web/src/EditMode.tsx b/web/src/EditMode.tsx index d95b0ef..937bf1c 100644 --- a/web/src/EditMode.tsx +++ b/web/src/EditMode.tsx @@ -10,7 +10,7 @@ import HelpModal from './HelpModal'; interface Props { initial: Interface | null; - onDone: () => void; + onDone: (iface: Interface) => void; } function blankInterface(): Interface { @@ -229,7 +229,7 @@ export default function EditMode({ initial, onDone }: Props) { function handleLeave() { if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return; - onDone(); + onDone(iface); } // ── Keyboard shortcuts ───────────────────────────────────────────────────── diff --git a/web/src/InfoPanel.tsx b/web/src/InfoPanel.tsx new file mode 100644 index 0000000..8bbfea6 --- /dev/null +++ b/web/src/InfoPanel.tsx @@ -0,0 +1,121 @@ +import { h } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { getMetaStore, getSignalStore } from './lib/stores'; +import type { SignalRef, SignalMeta, SignalValue } from './lib/types'; + +interface Props { + signal: SignalRef; + onClose: () => void; +} + +function qualityColor(q: string): string { + switch (q) { + case 'good': return '#4ade80'; + case 'uncertain': return '#fbbf24'; + case 'bad': return '#f87171'; + default: return '#6b7280'; + } +} + +function fmt(v: any): string { + if (v === null || v === undefined) return '—'; + if (typeof v === 'number') return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v); + if (Array.isArray(v)) return `[${v.slice(0, 8).map((x: any) => fmt(x)).join(', ')}${v.length > 8 ? ', …' : ''}]`; + return String(v); +} + +function fmtTs(ts: string | null): string { + if (!ts) return '—'; + try { return new Date(ts).toLocaleString(); } catch { return ts; } +} + +export default function InfoPanel({ signal, onClose }: Props) { + const [meta, setMeta] = useState(null); + const [sv, setSv] = useState({ value: null, quality: 'unknown', ts: null }); + + useEffect(() => { + const unsubM = getMetaStore(signal).subscribe(setMeta); + const unsubV = getSignalStore(signal).subscribe(setSv); + return () => { unsubM(); unsubV(); }; + }, [signal.ds, signal.name]); + + return ( +
+
+ Signal Info + +
+
+ + + + + +
Datasource{signal.ds}
Name{signal.name}
+ +
+ + + + + + + + + + +
Value + + {fmt(sv.value)}{meta?.unit ? ` ${meta.unit}` : ''} +
Quality{sv.quality}
Timestamp{fmtTs(sv.ts)}
+ + {meta && ( +
+
+ + + + {meta.unit && } + + {(meta.displayLow !== 0 || meta.displayHigh !== 0) && ( + + + + + )} + +
Type{meta.type}
Unit{meta.unit}
Writable{meta.writable ? 'Yes' : 'No'}
Display range{meta.displayLow} … {meta.displayHigh}
+ + {meta.enumStrings && meta.enumStrings.length > 0 && ( +
+
+
States
+ + + {meta.enumStrings.map((s, i) => ( + + + + + ))} + +
{i}{s}
+
+ )} + + {meta.description && ( +
+
+
Description
+
{meta.description}
+
+ )} +
+ )} +
+
+ ); +} diff --git a/web/src/PlotPanel.tsx b/web/src/PlotPanel.tsx new file mode 100644 index 0000000..55ad38f --- /dev/null +++ b/web/src/PlotPanel.tsx @@ -0,0 +1,278 @@ +import { h } from 'preact'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import uPlot from 'uplot'; +import { getSignalStore, getMetaStore } from './lib/stores'; +import type { SignalRef, SignalMeta } from './lib/types'; + +export interface PlotSignal { + ref: SignalRef; + color: string; +} + +interface Props { + signals: PlotSignal[]; + onRemove: (ref: SignalRef) => void; +} + +interface Buf { ts: number[]; vals: number[]; } + +// Large enough to hold a full 1-hour window at ~10 Hz per signal. +const RING_MAX = 50_000; + +const TIME_WINDOWS = [ + { label: '10s', secs: 10 }, + { label: '30s', secs: 30 }, + { label: '1m', secs: 60 }, + { label: '5m', secs: 300 }, + { label: '15m', secs: 900 }, + { label: '1h', secs: 3600 }, +]; + +interface Stat { min: number; max: number; mean: number; last: number; } + +function pushSample(buf: Buf, t: number, v: number) { + buf.ts.push(t); + buf.vals.push(v); + if (buf.ts.length > RING_MAX) { + buf.ts.splice(0, buf.ts.length - RING_MAX); + buf.vals.splice(0, buf.vals.length - RING_MAX); + } +} + +function buildData(signals: PlotSignal[], bufs: Map, windowSec: number): uPlot.AlignedData { + const cutoff = Date.now() / 1000 - windowSec; + const allTs = new Set(); + for (const s of signals) { + const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`); + if (buf) for (const t of buf.ts) if (t >= cutoff) allTs.add(t); + } + const sorted = Array.from(allTs).sort((a, b) => a - b); + if (sorted.length === 0) return [[], ...signals.map(() => [])] as uPlot.AlignedData; + return [ + sorted, + ...signals.map(s => { + const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`); + if (!buf) return sorted.map(() => null); + const m = new Map(); + buf.ts.forEach((t, i) => { if (t >= cutoff) m.set(t, buf.vals[i]); }); + return sorted.map(t => m.get(t) ?? null); + }), + ] as uPlot.AlignedData; +} + +function computeStats(buf: Buf | undefined, windowSec: number): Stat | null { + if (!buf || buf.ts.length === 0) return null; + const cutoff = Date.now() / 1000 - windowSec; + let min = Infinity, max = -Infinity, sum = 0, n = 0, last = NaN; + buf.ts.forEach((t, i) => { + if (t >= cutoff) { const v = buf.vals[i]; if (v < min) min = v; if (v > max) max = v; sum += v; n++; last = v; } + }); + return n > 0 ? { min, max, mean: sum / n, last } : null; +} + +function fmtN(v: number | undefined | null): string { + if (v === undefined || v === null || !isFinite(v)) return '—'; + const a = Math.abs(v); + if (a === 0) return '0'; + if (a >= 10000 || (a < 0.001 && a > 0)) return v.toExponential(3); + return v.toPrecision(4).replace(/\.?0+$/, ''); +} + +export default function PlotPanel({ signals, onRemove }: Props) { + const chartRef = useRef(null); + const [windowSec, setWindowSec] = useState(60); + const [stats, setStats] = useState>([]); + const [metas, setMetas] = useState>(new Map()); + + // Persistent ring buffers survive signal list changes (so we don't lose history) + const bufsRef = useRef>(new Map()); + + const signalsKey = signals.map(s => `${s.ref.ds}:${s.ref.name}:${s.color}`).join(','); + + useEffect(() => { + if (!chartRef.current) return; + const el = chartRef.current; + + // Ensure buffers exist for all current signals + for (const s of signals) { + const key = `${s.ref.ds}\0${s.ref.name}`; + if (!bufsRef.current.has(key)) bufsRef.current.set(key, { ts: [], vals: [] }); + } + + if (signals.length === 0) return; + + // Must be defined BEFORE new uPlot() because the range function closes over it + // and uPlot calls range() synchronously during construction. + let currentWindow = windowSec; + + // Create uPlot + let uplot = new uPlot( + { + width: Math.max(el.clientWidth, 200), + height: Math.max(el.clientHeight, 100), + legend: { show: false }, + cursor: { show: true }, + axes: [ + { stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, + { + stroke: '#64748b', + grid: { stroke: '#2d3748' }, + ticks: { stroke: '#475569' }, + values: (_u: uPlot, vals: (number | null)[]) => + vals.map(v => (v === null || v === undefined) ? '' : fmtN(v)), + }, + ], + scales: { + x: { + time: true, + range: (_u: uPlot, _min: number, _max: number): [number, number] => { + const now = Date.now() / 1000; + return [now - currentWindow, now]; + }, + }, + y: { auto: true }, + }, + series: [ + {}, + ...signals.map(s => ({ + label: s.ref.name, + stroke: s.color, + width: 1.5, + spanGaps: false, + })), + ], + }, + buildData(signals, bufsRef.current, windowSec), + el, + ); + + // Subscriptions to signal stores + const unsubs: Array<() => void> = []; + let dirty = false; + + signals.forEach(s => { + const key = `${s.ref.ds}\0${s.ref.name}`; + + const unsubV = getSignalStore(s.ref).subscribe(sv => { + if (sv.value === null || sv.ts === null) return; + const v = typeof sv.value === 'number' ? sv.value : + Array.isArray(sv.value) ? sv.value[0] : + parseFloat(String(sv.value)); + if (!isFinite(v)) return; + const buf = bufsRef.current.get(key); + if (buf) { pushSample(buf, new Date(sv.ts).getTime() / 1000, v); dirty = true; } + }); + unsubs.push(unsubV); + + const unsubM = getMetaStore(s.ref).subscribe(m => { + if (m) setMetas(prev => new Map(prev).set(key, m)); + }); + unsubs.push(unsubM); + }); + + // Expose a setter for the window so the windowSec useEffect can poke it + (el as any)._setWindow = (secs: number) => { currentWindow = secs; dirty = true; }; + + // RAF update loop — redraws on new data, every ~1 s to advance the time axis, + // or immediately when the chart becomes visible again after being hidden. + // Skips updates while the chart is hidden (display:none → 0×0) to prevent + // uPlot internal state corruption. + let lastDrawTime = 0; + let wasVisible = false; + let rafId = requestAnimationFrame(function tick(ts) { + const isVisible = el.clientWidth > 0 && el.clientHeight > 0; + const becameVisible = isVisible && !wasVisible; + wasVisible = isVisible; + if (isVisible && (dirty || becameVisible || ts - lastDrawTime >= 1000)) { + dirty = false; + lastDrawTime = ts; + uplot.setData(buildData(signals, bufsRef.current, currentWindow)); + setStats(signals.map(s => + computeStats(bufsRef.current.get(`${s.ref.ds}\0${s.ref.name}`), currentWindow) + )); + } + rafId = requestAnimationFrame(tick); + }); + + // Resize observer + const ro = new ResizeObserver(() => { + const w = el.clientWidth, h = el.clientHeight; + if (w > 0 && h > 0) uplot.setSize({ width: w, height: h }); + }); + ro.observe(el); + + return () => { + cancelAnimationFrame(rafId); + ro.disconnect(); + unsubs.forEach(u => u()); + uplot.destroy(); + delete (el as any)._setWindow; + }; + }, [signalsKey]); + + // When the window selector changes, poke the running chart + useEffect(() => { + if (chartRef.current) (chartRef.current as any)._setWindow?.(windowSec); + }, [windowSec]); + + return ( +
+
+ Live Plot +
+ {TIME_WINDOWS.map(tw => ( + + ))} +
+
+ +
+ {signals.length === 0 ? ( +
+ Right-click any widget in the HMI view and choose Plot to add signals here. +
+ ) : ( +
+
+ {signals.map((s, i) => { + const key = `${s.ref.ds}\0${s.ref.name}`; + const st = stats[i] ?? null; + const meta = metas.get(key); + const unit = meta?.unit ?? ''; + return ( +
+
+ + + {s.ref.name} + + +
+ + + + + + + + + + +
Last{st ? `${fmtN(st.last)}${unit ? ' ' + unit : ''}` : '—'}
Min{st ? fmtN(st.min) : '—'}
Max{st ? fmtN(st.max) : '—'}
Mean{st ? fmtN(st.mean) : '—'}
+
+ ); + })} +
+
+
+ )} +
+
+ ); +} diff --git a/web/src/PropertiesPane.tsx b/web/src/PropertiesPane.tsx index 840a63a..e7c8bb9 100644 --- a/web/src/PropertiesPane.tsx +++ b/web/src/PropertiesPane.tsx @@ -37,6 +37,8 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) = // Widgets that show units from signal metadata (can be overridden) const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']); +// Widgets where the user can set a numeric format string +const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']); // Widgets with a signal-based label (can be overridden) const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']); // Widgets with multiple signals @@ -154,24 +156,34 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange, signal widgets use it as header label */} {w.type !== 'image' && w.type !== 'link' && ( - setOpt('label', v)} - /> - {LABEL_WIDGETS.has(w.type) && ( - Empty = signal name + {LABEL_WIDGETS.has(w.type) ? ( +
+ setOpt('label', v)} /> + Empty = signal name +
+ ) : ( + setOpt('label', v)} /> )}
)} {/* Unit override for signal widgets */} {UNIT_WIDGETS.has(w.type) && ( - - setOpt('unit', v)} - /> - Empty = from metadata · "none" = hide + +
+ setOpt('unit', v)} /> + Empty = from meta · "none" = hide +
+
+ )} + + {/* Format string for numeric display */} + {FORMAT_WIDGETS.has(w.type) && ( + +
+ setOpt('format', v)} /> + 3f · 2e · 4g · empty=auto +
)} @@ -271,6 +283,12 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange, setOpt('yMax', v)} /> + +
+ setOpt('format', v)} /> + 3f · 2e · 4g · empty=auto +
+
setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)} + /> +
+ ))} +
+ ); + })} + +
+ + +
+
+ )} +
+ + +
+
+ ); +} diff --git a/web/src/SyntheticWizard.tsx b/web/src/SyntheticWizard.tsx index da976a7..4b62652 100644 --- a/web/src/SyntheticWizard.tsx +++ b/web/src/SyntheticWizard.tsx @@ -14,16 +14,14 @@ interface NodeParam { } const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [ - { type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'factor', type: 'number', default: '1' }] }, - { type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, - { type: 'moving_avg', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] }, - { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] }, - { type: 'lowpass', label: 'Lowpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] }, - { type: 'highpass', label: 'Highpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] }, - { type: 'derivative', label: 'Derivative', params: [] }, - { type: 'integral', label: 'Integral', params: [] }, - { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, - { type: 'formula', label: 'Formula', params: [{ label: 'Expression (use "x")', key: 'expr', type: 'text', default: 'x * 1.0' }] }, + { type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] }, + { type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, + { type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, + { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, + { type: 'derivative', label: 'Derivative', params: [] }, + { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, + { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, + { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] }, ]; export default function SyntheticWizard({ onClose, onCreated }: Props) { diff --git a/web/src/ViewMode.tsx b/web/src/ViewMode.tsx index fbf0e01..9a3fa33 100644 --- a/web/src/ViewMode.tsx +++ b/web/src/ViewMode.tsx @@ -2,14 +2,17 @@ import { h } from 'preact'; import { useState, useMemo, useEffect } from 'preact/hooks'; import InterfaceList from './InterfaceList'; import Canvas from './Canvas'; +import PlotPanel from './PlotPanel'; +import type { PlotSignal } from './PlotPanel'; import { wsClient } from './lib/ws'; import { parseInterface } from './lib/xml'; -import type { Interface } from './lib/types'; +import type { Interface, SignalRef } from './lib/types'; import ContextualHelp from './ContextualHelp'; import HelpModal from './HelpModal'; interface Props { onEdit?: (iface?: Interface) => void; + initialInterface?: Interface | null; } /** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */ @@ -18,13 +21,19 @@ function toLocalInput(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } -export default function ViewMode({ onEdit }: Props) { - const [currentInterface, setCurrentInterface] = useState(null); +const PLOT_COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c']; + +type ViewTab = 'hmi' | 'plot'; + +export default function ViewMode({ onEdit, initialInterface }: Props) { + const [currentInterface, setCurrentInterface] = useState(initialInterface ?? null); const [parseError, setParseError] = useState(null); const [wsStatus, setWsStatus] = useState('connecting'); const [showHelp, setShowHelp] = useState(false); const [helpSection, setHelpSection] = useState('start'); const [showTimeNav, setShowTimeNav] = useState(false); + const [viewTab, setViewTab] = useState('hmi'); + const [plotSignals, setPlotSignals] = useState([]); function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); } @@ -39,6 +48,11 @@ export default function ViewMode({ onEdit }: Props) { return unsub; }, []); + // When returning from edit mode, show the edited interface + useEffect(() => { + if (initialInterface) setCurrentInterface(initialInterface); + }, [initialInterface]); + function handleLoad(xml: string) { try { setParseError(null); @@ -81,6 +95,20 @@ export default function ViewMode({ onEdit }: Props) { setTimeRange(null); } + function addToPlot(ref: SignalRef) { + const key = `${ref.ds}\0${ref.name}`; + const already = plotSignals.some(s => `${s.ref.ds}\0${s.ref.name}` === key); + if (!already) { + const color = PLOT_COLORS[plotSignals.length % PLOT_COLORS.length]; + setPlotSignals(prev => [...prev, { ref, color }]); + } + setViewTab('plot'); + } + + function removeFromPlot(ref: SignalRef) { + setPlotSignals(prev => prev.filter(s => !(s.ref.ds === ref.ds && s.ref.name === ref.name))); + } + const isLive = timeRange === null; return ( @@ -172,12 +200,43 @@ export default function ViewMode({ onEdit }: Props) { const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); handleLoad(await res.text()); + setViewTab('hmi'); } catch (err) { setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`); } }} /> - + +
+ {/* Tab bar */} +
+ + +
+ + {/* Keep both panels mounted so ring-buffer data is never lost on tab switch */} +
+ +
+
+ +
+
{showHelp && ( diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..71935e5 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,44 @@ +/** + * formatValue formats a numeric value according to a format string. + * + * Format string grammar: [width.][precision]type + * type f — fixed-point: toFixed(precision) + * e — exponential: toExponential(precision) + * g — significant: toPrecision(precision), trailing zeros stripped + * + * Examples: "3f", "0.3f", "2e", "0.1e", "4g" + * The optional leading "0." is ignored — only the digits before the type + * letter are used as precision. + * + * An empty or unrecognised format falls back to the default display + * (4 significant figures, trailing zeros stripped, exponential for very + * large or very small values). + */ +export function formatValue(v: number | null | undefined, fmt: string): string { + if (v === null || v === undefined || typeof v !== 'number') return '---'; + if (!Number.isFinite(v)) return String(v); + + const s = fmt.trim(); + if (s) { + // Match optional "0." prefix then digits then type letter + const m = s.match(/^(?:\d+\.)?(\d+)([fFeEgG])$/); + if (m) { + const prec = Math.max(0, parseInt(m[1], 10)); + const type = m[2].toLowerCase(); + try { + switch (type) { + case 'f': return v.toFixed(prec); + case 'e': return v.toExponential(prec); + case 'g': return v.toPrecision(Math.max(1, prec)).replace(/\.?0+$/, ''); + } + } catch { + // Fall through to default + } + } + } + + // Default: 4 significant figures, exponential for extreme magnitudes + const a = Math.abs(v); + if (a !== 0 && (a >= 1e5 || a < 1e-3)) return v.toExponential(3); + return v.toPrecision(4).replace(/\.?0+$/, ''); +} diff --git a/web/src/styles.css b/web/src/styles.css index 82dde96..032dbf5 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1372,6 +1372,78 @@ body { padding: 0.4rem 0.6rem; } +/* ── Synthetic Editor ────────────────────────────────────────────────────── */ + +.synth-pipeline-node { + background: #1a2234; + border: 1px solid #2d3748; + border-radius: 6px; + padding: 0.5rem 0.6rem; + margin-bottom: 0.4rem; +} + +.synth-node-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; +} + +.synth-node-index { + font-size: 0.7rem; + color: #64748b; + background: #2d3748; + border-radius: 3px; + padding: 0.1rem 0.35rem; + min-width: 1.2rem; + text-align: center; +} + +.synth-node-label { + font-size: 0.8rem; + font-weight: 600; + color: #94a3b8; + flex: 1; +} + +.synth-node-actions { + display: flex; + gap: 0.2rem; +} + +.synth-add-node-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.6rem; + padding-top: 0.6rem; + border-top: 1px solid #2d3748; +} +.synth-add-node-row .prop-select { flex: 1; } + +.wizard-field-inline { + flex-direction: row !important; + align-items: center; + gap: 0.5rem !important; +} +.wizard-field-inline label { + min-width: 7rem; + flex-shrink: 0; +} +.wizard-field-inline .prop-input { flex: 1; } + +/* Edit button on synthetic signal items */ +.signal-edit-btn { + margin-left: auto; + font-size: 0.75rem; + opacity: 0; + transition: opacity 0.15s; + flex-shrink: 0; +} +.signal-item:hover .signal-edit-btn { opacity: 1; } +/* When edit button is present, remove-btn should not auto-margin */ +.signal-edit-btn + .signal-remove-btn { margin-left: 0; } + /* ── Properties Pane ─────────────────────────────────────────────────────── */ .props-panel { @@ -1433,6 +1505,15 @@ body { .prop-input-num { width: 70px; flex: none; } +/* Column wrapper: input on top, hint below — avoids squeezing input */ +.prop-field-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + .prop-select { flex: 1; background: #0f1117; @@ -2073,3 +2154,286 @@ kbd { margin-top: 0.15rem; line-height: 1.2; } + +/* ── View tabs ─────────────────────────────────────────────────────────── */ + +.view-content-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.view-tabs { + display: flex; + align-items: center; + gap: 2px; + padding: 0 0.5rem; + height: 36px; + background: #0f1117; + border-bottom: 1px solid #1e293b; + flex-shrink: 0; +} + +.view-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: #64748b; + font-size: 0.82rem; + padding: 0 0.75rem; + height: 100%; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.view-tab:hover { + color: #cbd5e1; +} + +.view-tab-active { + color: #e2e8f0; + border-bottom-color: #4a9eff; +} + +/* ── InfoPanel ─────────────────────────────────────────────────────────── */ + +.info-panel { + position: fixed; + top: 60px; + right: 16px; + z-index: 8000; + background: #1a1f2e; + border: 1px solid #2d3748; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + min-width: 260px; + max-width: 340px; + max-height: calc(100vh - 80px); + display: flex; + flex-direction: column; + font-family: system-ui, sans-serif; + font-size: 0.82rem; +} + +.info-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #2d3748; + flex-shrink: 0; +} + +.info-panel-title { + font-weight: 600; + color: #e2e8f0; + font-size: 0.85rem; +} + +.info-panel-close { + background: none; + border: none; + color: #64748b; + cursor: pointer; + font-size: 0.9rem; + padding: 0.1rem 0.3rem; + border-radius: 3px; +} +.info-panel-close:hover { background: #2d3748; color: #e2e8f0; } + +.info-panel-body { + overflow-y: auto; + padding: 0.5rem 0; +} + +.info-table { + width: 100%; + border-collapse: collapse; +} + +.info-key { + padding: 0.2rem 0.75rem; + color: #64748b; + font-size: 0.75rem; + white-space: nowrap; + vertical-align: top; + width: 36%; +} + +.info-val { + padding: 0.2rem 0.75rem 0.2rem 0; + color: #cbd5e1; + font-size: 0.8rem; + word-break: break-all; +} + +.info-mono { + font-family: ui-monospace, monospace; + font-size: 0.75rem; +} + +.info-sep { + height: 1px; + background: #1e293b; + margin: 0.4rem 0; +} + +.info-section-hdr { + padding: 0.2rem 0.75rem; + font-size: 0.7rem; + font-weight: 600; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-desc { + padding: 0.25rem 0.75rem; + color: #94a3b8; + font-size: 0.78rem; + line-height: 1.4; +} + +/* ── PlotPanel ─────────────────────────────────────────────────────────── */ + +.plot-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background: #0f1117; +} + +.plot-panel-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 0.75rem; + height: 40px; + background: #0f1117; + border-bottom: 1px solid #1e293b; + flex-shrink: 0; +} + +.plot-panel-title { + font-size: 0.8rem; + font-weight: 600; + color: #94a3b8; + margin-right: 0.5rem; + white-space: nowrap; +} + +.plot-window-btns { + display: flex; + gap: 2px; +} + +.plot-panel-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.plot-empty-hint { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #475569; + font-size: 0.88rem; + text-align: center; + padding: 2rem; +} + +.plot-panel-content { + display: contents; +} + +.plot-panel-legend { + width: 160px; + flex-shrink: 0; + overflow-y: auto; + border-right: 1px solid #1e293b; + padding: 0.5rem 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.plot-legend-item { + padding: 0.4rem 0.6rem; + border-radius: 4px; +} +.plot-legend-item:hover { background: #1a1f2e; } + +.plot-legend-hdr { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.3rem; +} + +.plot-legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.plot-legend-name { + flex: 1; + font-size: 0.75rem; + color: #cbd5e1; + font-family: ui-monospace, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plot-legend-rm { + background: none; + border: none; + color: #475569; + cursor: pointer; + font-size: 0.7rem; + padding: 0 0.15rem; + opacity: 0.6; +} +.plot-legend-rm:hover { opacity: 1; color: #f87171; } + +.plot-stats-tbl { + width: 100%; + border-collapse: collapse; + font-size: 0.72rem; +} +.plot-stats-tbl td { + padding: 0.05rem 0; + color: #475569; +} +.plot-stats-tbl td:first-child { + width: 44px; + color: #334155; + font-size: 0.68rem; +} +.plot-stats-tbl td:last-child { + color: #94a3b8; + font-family: ui-monospace, monospace; +} + +.plot-panel-chart { + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* uPlot inside plot-panel needs full dimensions */ +.plot-panel-chart .uplot, +.plot-panel-chart .u-wrap { + width: 100% !important; + height: 100% !important; +} diff --git a/web/src/widgets/BarH.tsx b/web/src/widgets/BarH.tsx index 6236e26..b8ca744 100644 --- a/web/src/widgets/BarH.tsx +++ b/web/src/widgets/BarH.tsx @@ -1,6 +1,7 @@ import { h } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import { getSignalStore, getMetaStore } from '../lib/stores'; +import { formatValue } from '../lib/format'; import type { Widget, SignalValue, SignalMeta } from '../lib/types'; const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null }; @@ -34,6 +35,7 @@ export default function BarH({ widget, onContextMenu }: Props) { const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0)); const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100)); const quality = sv.quality; + const fmt = widget.options['format'] ?? ''; const rawV = sv.value; const rawValue: number | null = rawV === null || rawV === undefined ? null @@ -41,7 +43,7 @@ export default function BarH({ widget, onContextMenu }: Props) { function displayValue(): string { if (rawValue === null || isNaN(rawValue)) return '---'; - return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue); + return formatValue(rawValue, fmt); } function fillPercent(): number { diff --git a/web/src/widgets/BarV.tsx b/web/src/widgets/BarV.tsx index d3839cd..4ed9c87 100644 --- a/web/src/widgets/BarV.tsx +++ b/web/src/widgets/BarV.tsx @@ -1,6 +1,7 @@ import { h } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import { getSignalStore, getMetaStore } from '../lib/stores'; +import { formatValue } from '../lib/format'; import type { Widget, SignalValue, SignalMeta } from '../lib/types'; const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null }; @@ -34,6 +35,7 @@ export default function BarV({ widget, onContextMenu }: Props) { const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0)); const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100)); const quality = sv.quality; + const fmt = widget.options['format'] ?? ''; const rawV = sv.value; const rawValue: number | null = rawV === null || rawV === undefined ? null @@ -41,7 +43,7 @@ export default function BarV({ widget, onContextMenu }: Props) { function displayValue(): string { if (rawValue === null || isNaN(rawValue)) return '---'; - return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue); + return formatValue(rawValue, fmt); } function fillPercent(): number { diff --git a/web/src/widgets/Gauge.tsx b/web/src/widgets/Gauge.tsx index fbbdbdf..4c47e93 100644 --- a/web/src/widgets/Gauge.tsx +++ b/web/src/widgets/Gauge.tsx @@ -1,6 +1,7 @@ import { h } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import { getSignalStore, getMetaStore } from '../lib/stores'; +import { formatValue } from '../lib/format'; import type { Widget, SignalValue, SignalMeta } from '../lib/types'; const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null }; @@ -60,13 +61,14 @@ export default function Gauge({ widget, onContextMenu }: Props) { const thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null; const quality = sv.quality; + const fmt = widget.options['format'] ?? ''; const rawV = sv.value; const rawValue: number | null = rawV === null || rawV === undefined ? null : typeof rawV === 'number' ? rawV : parseFloat(String(rawV)); function displayValue(): string { if (rawValue === null || isNaN(rawValue)) return '---'; - return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue); + return formatValue(rawValue, fmt); } function fillColor(): string { diff --git a/web/src/widgets/PlotWidget.tsx b/web/src/widgets/PlotWidget.tsx index 3a746be..04c5bb4 100644 --- a/web/src/widgets/PlotWidget.tsx +++ b/web/src/widgets/PlotWidget.tsx @@ -4,6 +4,7 @@ import uPlot from 'uplot'; import * as echarts from 'echarts'; import { getSignalStore } from '../lib/stores'; import { wsClient } from '../lib/ws'; +import { formatValue } from '../lib/format'; import type { Widget, SignalRef } from '../lib/types'; interface Props { @@ -82,6 +83,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] })); const histValues: number[][] = signals.map(() => []); const unsubs: (() => void)[] = []; + const fmt = widget.options['format'] ?? ''; // ── uPlot (timeseries) ────────────────────────────────────────────────── let uplot: uPlot | null = null; @@ -110,6 +112,37 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) ] as uPlot.AlignedData; } + function makeUplotOpts(): uPlot.Options { + const yAxis: uPlot.Axis = { + stroke: '#64748b', + grid: { stroke: '#2d3748' }, + ticks: { stroke: '#475569' }, + }; + if (fmt) { + yAxis.values = (_u: uPlot, vals: (number | null)[]) => + vals.map(v => (v === null || v === undefined) ? '' : formatValue(v, fmt)); + } + const seriesConf: uPlot.Series[] = [ + {}, + ...signals.map((s, i) => ({ + label: s.name, + stroke: s.color ?? COLORS[i % COLORS.length], + width: 1.5, + })), + ]; + return { + width: w, + height: h, + series: seriesConf, + legend: { show: showLegend }, + axes: [ + { stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, + yAxis, + ], + scales: { x: { time: true }, y: scaleY }, + }; + } + // ── ECharts ──────────────────────────────────────────────────────────── let echart: echarts.EChartsType | null = null; @@ -219,37 +252,15 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin); if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax); - if (plotType === 'timeseries') { - const seriesConf: uPlot.Series[] = [ - {}, - ...signals.map((s, i) => ({ - label: s.name, - stroke: s.color ?? COLORS[i % COLORS.length], - width: 1.5, - })), - ]; - - uplot = new uPlot( - { - width: w, - height: h, - series: seriesConf, - legend: { show: showLegend }, - axes: [ - { stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, - { stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, - ], - scales: { x: { time: true }, y: scaleY }, - }, - uplotData(), - el, - ); - } else { + if (plotType !== 'timeseries') { echart = echarts.init(el); echart.setOption(echartsOption()); } // ── Historical mode (timeseries only) ────────────────────────────────── + // uPlot is NOT created until data arrives — initialising with empty arrays + // causes the x-axis to start at epoch 0, compressing all real data to the + // right edge of the chart. if (timeRange && plotType === 'timeseries') { setHistStatus('loading'); let cancelled = false; @@ -272,18 +283,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) if (cancelled) return; const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0); setHistStatus(totalPoints === 0 ? 'empty' : 'idle'); - if (uplot) uplot.setData(uplotData()); + // Create uPlot now — with real data — so the x-axis is correct from the start. + uplot = new uPlot(makeUplotOpts(), uplotData(), el); }).catch(() => { if (!cancelled) setHistStatus('error'); }); return () => { cancelled = true; - if (uplot) uplot.destroy(); - if (echart) echart.dispose(); + uplot?.destroy(); + echart?.dispose(); }; } + // ── Live mode: init uPlot immediately ────────────────────────────────── + if (plotType === 'timeseries') { + uplot = new uPlot(makeUplotOpts(), uplotData(), el); + } + // ── Live streaming mode ──────────────────────────────────────────────── setHistStatus('idle'); diff --git a/web/src/widgets/TextView.tsx b/web/src/widgets/TextView.tsx index 1b57410..097817a 100644 --- a/web/src/widgets/TextView.tsx +++ b/web/src/widgets/TextView.tsx @@ -1,6 +1,7 @@ import { h } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import { getSignalStore, getMetaStore } from '../lib/stores'; +import { formatValue } from '../lib/format'; import type { Widget, SignalValue, SignalMeta } from '../lib/types'; const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null }; @@ -32,13 +33,12 @@ export default function TextView({ widget, onContextMenu }: Props) { const rawUnit = widget.options['unit']; const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || ''); const quality = sv.quality; + const fmt = widget.options['format'] ?? ''; function displayValue(): string { if (!sv || sv.value === null || sv.value === undefined) return '---'; const v = sv.value; - if (typeof v === 'number') { - return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v); - } + if (typeof v === 'number') return formatValue(v, fmt); return String(v); }