working epics ioc and tested
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+150
-33
@@ -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)
|
||||
|
||||
+11
-5
@@ -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():
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-4
@@ -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()
|
||||
|
||||
+45
-16
@@ -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:
|
||||
|
||||
+12
-5
@@ -15,6 +15,8 @@ export default function App() {
|
||||
const [mode, setMode] = useState<AppMode>('view');
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
||||
// Interface to show when returning to view mode after editing
|
||||
const [viewTarget, setViewTarget] = useState<Interface | null>(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' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view'
|
||||
? <ViewMode onEdit={enterEdit} />
|
||||
: <EditMode initial={editTarget} onDone={exitEdit} />
|
||||
}
|
||||
{/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed.
|
||||
EditMode is conditionally rendered; it resets from props each time anyway. */}
|
||||
<div style={`display:${mode === 'view' ? 'contents' : 'none'}`}>
|
||||
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} />
|
||||
</div>
|
||||
{mode === 'edit' && (
|
||||
<EditMode initial={editTarget} onDone={exitEdit} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+24
-2
@@ -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<string, any> = {
|
||||
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<CtxState>({
|
||||
visible: false, x: 0, y: 0, signal: null,
|
||||
});
|
||||
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(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 (
|
||||
<div class="canvas-container">
|
||||
@@ -90,10 +105,17 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
{...ctxMenu}
|
||||
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
|
||||
onClose={closeCtxMenu}
|
||||
onInfo={handleInfo}
|
||||
onPlot={onPlot ? handlePlot : undefined}
|
||||
/>
|
||||
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-4
@@ -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<SignalMeta | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(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) {
|
||||
)}
|
||||
</div>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={openInfo}>Signal info</button>
|
||||
<button class="ctx-item" onClick={openPlot}>Plot</button>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
|
||||
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||
</div>
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<SignalMeta | null>(null);
|
||||
const [sv, setSv] = useState<SignalValue>({ 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 (
|
||||
<div class="info-panel">
|
||||
<div class="info-panel-header">
|
||||
<span class="info-panel-title">Signal Info</span>
|
||||
<button class="info-panel-close" onClick={onClose} title="Close">✕</button>
|
||||
</div>
|
||||
<div class="info-panel-body">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Datasource</td><td class="info-val info-mono">{signal.ds}</td></tr>
|
||||
<tr><td class="info-key">Name</td><td class="info-val info-mono">{signal.name}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="info-sep" />
|
||||
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="info-key">Value</td>
|
||||
<td class="info-val">
|
||||
<span
|
||||
class="quality-dot"
|
||||
style={`background:${qualityColor(sv.quality)};display:inline-block;margin-right:5px;vertical-align:middle;`}
|
||||
/>
|
||||
{fmt(sv.value)}{meta?.unit ? ` ${meta.unit}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td class="info-key">Quality</td><td class="info-val">{sv.quality}</td></tr>
|
||||
<tr><td class="info-key">Timestamp</td><td class="info-val">{fmtTs(sv.ts)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Type</td><td class="info-val">{meta.type}</td></tr>
|
||||
{meta.unit && <tr><td class="info-key">Unit</td><td class="info-val">{meta.unit}</td></tr>}
|
||||
<tr><td class="info-key">Writable</td><td class="info-val">{meta.writable ? 'Yes' : 'No'}</td></tr>
|
||||
{(meta.displayLow !== 0 || meta.displayHigh !== 0) && (
|
||||
<tr>
|
||||
<td class="info-key">Display range</td>
|
||||
<td class="info-val">{meta.displayLow} … {meta.displayHigh}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta.enumStrings && meta.enumStrings.length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">States</div>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
{meta.enumStrings.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td class="info-key">{i}</td>
|
||||
<td class="info-val info-mono">{s}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.description && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Description</div>
|
||||
<div class="info-desc">{meta.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, Buf>, windowSec: number): uPlot.AlignedData {
|
||||
const cutoff = Date.now() / 1000 - windowSec;
|
||||
const allTs = new Set<number>();
|
||||
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<number, number>();
|
||||
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<HTMLDivElement>(null);
|
||||
const [windowSec, setWindowSec] = useState(60);
|
||||
const [stats, setStats] = useState<Array<Stat | null>>([]);
|
||||
const [metas, setMetas] = useState<Map<string, SignalMeta>>(new Map());
|
||||
|
||||
// Persistent ring buffers survive signal list changes (so we don't lose history)
|
||||
const bufsRef = useRef<Map<string, Buf>>(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 (
|
||||
<div class="plot-panel">
|
||||
<div class="plot-panel-toolbar">
|
||||
<span class="plot-panel-title">Live Plot</span>
|
||||
<div class="plot-window-btns">
|
||||
{TIME_WINDOWS.map(tw => (
|
||||
<button
|
||||
key={tw.secs}
|
||||
class={`toolbar-btn${windowSec === tw.secs ? ' toolbar-btn-active' : ''}`}
|
||||
onClick={() => setWindowSec(tw.secs)}
|
||||
>
|
||||
{tw.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plot-panel-body">
|
||||
{signals.length === 0 ? (
|
||||
<div class="plot-empty-hint">
|
||||
Right-click any widget in the <b>HMI</b> view and choose <b>Plot</b> to add signals here.
|
||||
</div>
|
||||
) : (
|
||||
<div class="plot-panel-content">
|
||||
<div class="plot-panel-legend">
|
||||
{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 (
|
||||
<div class="plot-legend-item" key={key}>
|
||||
<div class="plot-legend-hdr">
|
||||
<span class="plot-legend-swatch" style={`background:${s.color};`} />
|
||||
<span class="plot-legend-name" title={`${s.ref.ds} / ${s.ref.name}`}>
|
||||
{s.ref.name}
|
||||
</span>
|
||||
<button class="plot-legend-rm" onClick={() => onRemove(s.ref)} title="Remove">✕</button>
|
||||
</div>
|
||||
<table class="plot-stats-tbl">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Last</td>
|
||||
<td>{st ? `${fmtN(st.last)}${unit ? ' ' + unit : ''}` : '—'}</td>
|
||||
</tr>
|
||||
<tr><td>Min</td><td>{st ? fmtN(st.min) : '—'}</td></tr>
|
||||
<tr><td>Max</td><td>{st ? fmtN(st.max) : '—'}</td></tr>
|
||||
<tr><td>Mean</td><td>{st ? fmtN(st.mean) : '—'}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div class="plot-panel-chart" ref={chartRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-12
@@ -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' && (
|
||||
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
||||
<TextInput
|
||||
value={w.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
{LABEL_WIDGETS.has(w.type) && (
|
||||
<span class="prop-hint">Empty = signal name</span>
|
||||
{LABEL_WIDGETS.has(w.type) ? (
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
<span class="prop-hint">Empty = signal name</span>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Unit override for signal widgets */}
|
||||
{UNIT_WIDGETS.has(w.type) && (
|
||||
<Field label="Unit override">
|
||||
<TextInput
|
||||
value={w.options['unit'] ?? ''}
|
||||
onCommit={(v) => setOpt('unit', v)}
|
||||
/>
|
||||
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
|
||||
<Field label="Unit">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['unit'] ?? ''} onCommit={(v) => setOpt('unit', v)} />
|
||||
<span class="prop-hint">Empty = from meta · "none" = hide</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Format string for numeric display */}
|
||||
{FORMAT_WIDGETS.has(w.type) && (
|
||||
<Field label="Format">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
||||
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
@@ -271,6 +283,12 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<Field label="Y max">
|
||||
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
|
||||
</Field>
|
||||
<Field label="Y format">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
|
||||
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Legend">
|
||||
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
|
||||
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticEditor from './SyntheticEditor';
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
@@ -47,6 +48,7 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
const [cfAvailable, setCfAvailable] = useState(false);
|
||||
const [cfSearching, setCfSearching] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
||||
const csvRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function loadGroups() {
|
||||
@@ -284,6 +286,14 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
{group.ds === 'synthetic' && (
|
||||
<button
|
||||
class="icon-btn signal-edit-btn"
|
||||
title="Edit pipeline"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); setEditSynthetic(sig.name); }}
|
||||
>✎</button>
|
||||
)}
|
||||
{isCustom && (
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
@@ -330,6 +340,14 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
onCreated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editSynthetic && (
|
||||
<SyntheticEditor
|
||||
name={editSynthetic}
|
||||
onClose={() => setEditSynthetic(null)}
|
||||
onSaved={loadGroups}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text';
|
||||
default: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ 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' }] },
|
||||
];
|
||||
|
||||
interface PipelineNode {
|
||||
type: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SignalDef {
|
||||
name: string;
|
||||
ds: string;
|
||||
signal: string;
|
||||
pipeline: PipelineNode[];
|
||||
meta: {
|
||||
unit?: string;
|
||||
description?: string;
|
||||
displayLow?: number;
|
||||
displayHigh?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
||||
const [def, setDef] = useState<SignalDef | null>(null);
|
||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||
const [addType, setAddType] = useState('gain');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
||||
.then((d: SignalDef) => {
|
||||
setDef(d);
|
||||
setPipeline(d.pipeline ?? []);
|
||||
})
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
function nodeLabel(type: string): string {
|
||||
return NODE_TYPES.find(n => n.type === type)?.label ?? type;
|
||||
}
|
||||
|
||||
function nodeParamDefs(type: string): NodeParam[] {
|
||||
return NODE_TYPES.find(n => n.type === type)?.params ?? [];
|
||||
}
|
||||
|
||||
function setNodeParam(idx: number, key: string, val: string) {
|
||||
setPipeline(p => p.map((node, i) => {
|
||||
if (i !== idx) return node;
|
||||
const paramDef = nodeParamDefs(node.type).find(pd => pd.key === key);
|
||||
const typed: any = paramDef?.type === 'number' ? parseFloat(val) : val;
|
||||
return { ...node, params: { ...node.params, [key]: typed } };
|
||||
}));
|
||||
}
|
||||
|
||||
function removeNode(idx: number) {
|
||||
setPipeline(p => p.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function moveNode(idx: number, dir: -1 | 1) {
|
||||
setPipeline(p => {
|
||||
const next = [...p];
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= next.length) return next;
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
const nodeDef = NODE_TYPES.find(n => n.type === addType)!;
|
||||
const params: Record<string, any> = {};
|
||||
for (const p of nodeDef.params) {
|
||||
params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
||||
}
|
||||
setPipeline(p => [...p, { type: addType, params }]);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!def) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = { ...def, pipeline };
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={(e) => e.stopPropagation()} style="max-width:560px;">
|
||||
<div class="wizard-header">
|
||||
<span>Edit Pipeline — {name}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{error && <p class="wizard-error">{error}</p>}
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : (
|
||||
<div>
|
||||
<div class="wizard-section-title">Pipeline nodes</div>
|
||||
{pipeline.length === 0 && (
|
||||
<p class="hint" style="padding:0.5rem 0;">No processing nodes — input passes through unchanged.</p>
|
||||
)}
|
||||
{pipeline.map((node, idx) => {
|
||||
const paramDefs = nodeParamDefs(node.type);
|
||||
return (
|
||||
<div key={idx} class="synth-pipeline-node">
|
||||
<div class="synth-node-header">
|
||||
<span class="synth-node-index">{idx + 1}</span>
|
||||
<span class="synth-node-label">{nodeLabel(node.type)}</span>
|
||||
<div class="synth-node-actions">
|
||||
<button class="icon-btn" title="Move up" disabled={idx === 0} onClick={() => moveNode(idx, -1)}>↑</button>
|
||||
<button class="icon-btn" title="Move down" disabled={idx === pipeline.length - 1} onClick={() => moveNode(idx, 1)}>↓</button>
|
||||
<button class="icon-btn" title="Remove node" onClick={() => removeNode(idx)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{paramDefs.map(pd => (
|
||||
<div key={pd.key} class="wizard-field wizard-field-inline">
|
||||
<label>{pd.label}</label>
|
||||
<input
|
||||
class="prop-input"
|
||||
type={pd.type === 'number' ? 'number' : 'text'}
|
||||
value={node.params[pd.key] ?? pd.default}
|
||||
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="synth-add-node-row">
|
||||
<select class="prop-select" value={addType} onChange={(e) => setAddType((e.target as HTMLSelectElement).value)}>
|
||||
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
|
||||
</select>
|
||||
<button class="toolbar-btn" onClick={addNode}>+ Add node</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? 'Saving…' : 'Save Pipeline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+63
-4
@@ -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<Interface | null>(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<Interface | null>(initialInterface ?? null);
|
||||
const [parseError, setParseError] = useState<string | null>(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<ViewTab>('hmi');
|
||||
const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]);
|
||||
|
||||
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}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Canvas iface={currentInterface} onNavigate={handleNavigate} timeRange={timeRange} />
|
||||
|
||||
<div class="view-content-area">
|
||||
{/* Tab bar */}
|
||||
<div class="view-tabs">
|
||||
<button
|
||||
class={`view-tab${viewTab === 'hmi' ? ' view-tab-active' : ''}`}
|
||||
onClick={() => setViewTab('hmi')}
|
||||
>
|
||||
HMI
|
||||
</button>
|
||||
<button
|
||||
class={`view-tab${viewTab === 'plot' ? ' view-tab-active' : ''}`}
|
||||
onClick={() => setViewTab('plot')}
|
||||
>
|
||||
Plot{plotSignals.length > 0 ? ` (${plotSignals.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keep both panels mounted so ring-buffer data is never lost on tab switch */}
|
||||
<div style={`display:${viewTab === 'hmi' ? 'contents' : 'none'}`}>
|
||||
<Canvas
|
||||
iface={currentInterface}
|
||||
onNavigate={handleNavigate}
|
||||
timeRange={timeRange}
|
||||
onPlot={addToPlot}
|
||||
/>
|
||||
</div>
|
||||
<div style={`display:${viewTab === 'plot' ? 'contents' : 'none'}`}>
|
||||
<PlotPanel signals={plotSignals} onRemove={removeFromPlot} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
|
||||
@@ -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+$/, '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user