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
|
// Synthetic signal CRUD
|
||||||
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
|
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
|
||||||
mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
|
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)
|
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)
|
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) {
|
func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.synthetic == nil {
|
if h.synthetic == nil {
|
||||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
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.
|
// fetchMetadata retrieves metadata from a connected chid using ca_get.
|
||||||
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
|
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
|
||||||
// In EPICS, write access is governed by CA security (host/user rules), not
|
// ca_write_access returns 1 if CA security permits puts from this client.
|
||||||
// by the field type. Default to writable=true; the IOC will reject puts
|
// This reflects the IOC's actual security policy (host/user ACLs), so we
|
||||||
// that violate its security policy.
|
// use it directly rather than defaulting to true and relying on put failures.
|
||||||
meta := datasource.Metadata{Name: name, Writable: true}
|
meta := datasource.Metadata{Name: name, Writable: C.ca_write_access(chid) != 0}
|
||||||
|
|
||||||
fieldType := C.ca_field_type(chid)
|
fieldType := C.ca_field_type(chid)
|
||||||
count := C.ca_element_count(chid)
|
count := C.ca_element_count(chid)
|
||||||
|
|||||||
@@ -286,6 +286,52 @@ func (s *Synthetic) RemoveSignal(name string) error {
|
|||||||
return nil
|
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).
|
// GetDefs returns a copy of all current signal definitions (for the REST API).
|
||||||
func (s *Synthetic) GetDefs() []SignalDef {
|
func (s *Synthetic) GetDefs() []SignalDef {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|||||||
+149
-32
@@ -277,12 +277,21 @@ func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// exprParser is a recursive-descent parser for simple arithmetic expressions.
|
// exprParser is a recursive-descent parser for arithmetic expressions.
|
||||||
// Grammar:
|
// Grammar:
|
||||||
//
|
//
|
||||||
// expr = term (('+' | '-') term)*
|
// expr = term (('+' | '-') term)*
|
||||||
// term = factor (('*' | '/') factor)*
|
// term = power (('*' | '/') power)*
|
||||||
|
// power = unary ('^' power)* — right-associative exponentiation
|
||||||
|
// unary = '-' unary | call
|
||||||
|
// call = ident '(' expr ')' | factor
|
||||||
// factor = '(' expr ')' | number | variable
|
// 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 {
|
type exprParser struct {
|
||||||
src string
|
src string
|
||||||
pos int
|
pos int
|
||||||
@@ -328,7 +337,7 @@ func (p *exprParser) parseExpr() (float64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *exprParser) parseTerm() (float64, error) {
|
func (p *exprParser) parseTerm() (float64, error) {
|
||||||
left, err := p.parseFactor()
|
left, err := p.parsePower()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -338,7 +347,7 @@ func (p *exprParser) parseTerm() (float64, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
p.pos++
|
p.pos++
|
||||||
right, err := p.parseFactor()
|
right, err := p.parsePower()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -354,6 +363,136 @@ func (p *exprParser) parseTerm() (float64, error) {
|
|||||||
return left, nil
|
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) {
|
func (p *exprParser) parseFactor() (float64, error) {
|
||||||
p.skipSpaces()
|
p.skipSpaces()
|
||||||
if p.pos >= len(p.src) {
|
if p.pos >= len(p.src) {
|
||||||
@@ -362,7 +501,7 @@ func (p *exprParser) parseFactor() (float64, error) {
|
|||||||
|
|
||||||
ch := p.src[p.pos]
|
ch := p.src[p.pos]
|
||||||
|
|
||||||
// Parenthesised subexpression
|
// Parenthesised subexpression.
|
||||||
if ch == '(' {
|
if ch == '(' {
|
||||||
p.pos++
|
p.pos++
|
||||||
val, err := p.parseExpr()
|
val, err := p.parseExpr()
|
||||||
@@ -377,35 +516,13 @@ func (p *exprParser) parseFactor() (float64, error) {
|
|||||||
return val, nil
|
return val, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unary minus
|
// Number (including optional decimal point and exponent notation).
|
||||||
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)
|
|
||||||
if ch >= '0' && ch <= '9' || ch == '.' {
|
if ch >= '0' && ch <= '9' || ch == '.' {
|
||||||
start := p.pos
|
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++
|
p.pos++
|
||||||
}
|
}
|
||||||
f, err := strconv.ParseFloat(p.src[start:p.pos], 64)
|
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)
|
wg.Add(3)
|
||||||
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
|
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
|
||||||
go func() { defer wg.Done(); c.dispatchLoop(ctx) }()
|
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()
|
wg.Wait()
|
||||||
|
|
||||||
// Cancel all subscriptions on disconnect.
|
// Cancel all subscriptions on disconnect.
|
||||||
@@ -193,7 +193,10 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeLoop sends queued messages over the WebSocket connection.
|
// 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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg := <-c.outCh:
|
case msg := <-c.outCh:
|
||||||
@@ -251,8 +254,11 @@ func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) {
|
|||||||
c.subs[ref] = cancelSub
|
c.subs[ref] = cancelSub
|
||||||
c.subsMu.Unlock()
|
c.subsMu.Unlock()
|
||||||
|
|
||||||
// Send metadata once on subscribe.
|
// Send metadata in a goroutine: for EPICS signals GetMetadata may block
|
||||||
c.sendMeta(ctx, ref)
|
// 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)
|
b, _ := json.Marshal(resp)
|
||||||
select {
|
select {
|
||||||
case c.outCh <- b:
|
case c.outCh <- b:
|
||||||
default:
|
case <-ctx.Done():
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"os/user"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -42,11 +42,18 @@ type Config struct {
|
|||||||
// EPICS_CA_ADDR_LIST — space-separated list of server addresses
|
// EPICS_CA_ADDR_LIST — space-separated list of server addresses
|
||||||
// EPICS_CA_AUTO_ADDR_LIST — "NO" disables automatic broadcast addresses
|
// EPICS_CA_AUTO_ADDR_LIST — "NO" disables automatic broadcast addresses
|
||||||
func ConfigFromEnv() Config {
|
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()
|
host, _ := os.Hostname()
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
AutoAddrList: true,
|
AutoAddrList: true,
|
||||||
ClientName: name,
|
ClientName: clientName,
|
||||||
HostName: host,
|
HostName: host,
|
||||||
}
|
}
|
||||||
if v := os.Getenv("EPICS_CA_ADDR_LIST"); v != "" {
|
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 == "" {
|
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 == "" {
|
if cfg.HostName == "" {
|
||||||
cfg.HostName, _ = os.Hostname()
|
cfg.HostName, _ = os.Hostname()
|
||||||
|
|||||||
+39
-10
@@ -62,7 +62,9 @@ type chanState struct {
|
|||||||
dbfType int // native DBF field type
|
dbfType int // native DBF field type
|
||||||
count uint32 // element count
|
count uint32 // element count
|
||||||
access uint32 // AccessRead | AccessWrite bitmask
|
access uint32 // AccessRead | AccessWrite bitmask
|
||||||
readyC chan struct{} // closed once CREATE_CHAN reply is received
|
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
|
monitors []*monState // active subscriptions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +84,8 @@ func newChanState(cid uint32, pvName string) *chanState {
|
|||||||
func (cs *chanState) resetForReconnect() {
|
func (cs *chanState) resetForReconnect() {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
cs.sid = 0
|
cs.sid = 0
|
||||||
|
cs.gotChan = false
|
||||||
|
cs.gotAccess = false
|
||||||
old := cs.readyC
|
old := cs.readyC
|
||||||
cs.readyC = make(chan struct{})
|
cs.readyC = make(chan struct{})
|
||||||
cs.mu.Unlock()
|
cs.mu.Unlock()
|
||||||
@@ -94,25 +98,25 @@ func (cs *chanState) resetForReconnect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitReady blocks until sid != 0 (CREATE_CHAN reply received for the current
|
// waitReady blocks until both CREATE_CHAN and ACCESS_RIGHTS have been received
|
||||||
// connection) or ctx expires. It loops through reconnections automatically:
|
// (i.e. sid != 0 and gotAccess == true) or ctx expires. It loops through
|
||||||
// when resetForReconnect closes the current readyC the goroutine wakes, sees
|
// reconnections automatically: when resetForReconnect closes the current readyC
|
||||||
// sid == 0, and waits on the freshly created channel.
|
// the goroutine wakes, sees sid == 0, and waits on the freshly created channel.
|
||||||
func (cs *chanState) waitReady(ctx context.Context) error {
|
func (cs *chanState) waitReady(ctx context.Context) error {
|
||||||
for {
|
for {
|
||||||
cs.mu.RLock()
|
cs.mu.RLock()
|
||||||
sid := cs.sid
|
sid := cs.sid
|
||||||
|
gotAccess := cs.gotAccess
|
||||||
ready := cs.readyC
|
ready := cs.readyC
|
||||||
cs.mu.RUnlock()
|
cs.mu.RUnlock()
|
||||||
|
|
||||||
if sid != 0 {
|
if sid != 0 && gotAccess {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ready:
|
case <-ready:
|
||||||
// State changed — either CREATE_CHAN reply (sid set) or reconnect
|
// State changed — loop to re-check sid and gotAccess.
|
||||||
// started (sid cleared, new readyC installed). Loop to check.
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
@@ -378,7 +382,11 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) {
|
|||||||
cs.sid = hdr.Parameter2
|
cs.sid = hdr.Parameter2
|
||||||
cs.dbfType = int(hdr.DataType)
|
cs.dbfType = int(hdr.DataType)
|
||||||
cs.count = hdr.DataCount
|
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.
|
// Snapshot monitors for EVENT_ADD re-subscription.
|
||||||
mons := make([]*monState, len(cs.monitors))
|
mons := make([]*monState, len(cs.monitors))
|
||||||
copy(mons, cs.monitors)
|
copy(mons, cs.monitors)
|
||||||
@@ -397,7 +405,12 @@ func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) {
|
|||||||
default:
|
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:
|
case proto.CmdCreateFail:
|
||||||
// Server does not host this PV (or quota exceeded).
|
// 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:
|
case proto.CmdAccessRights:
|
||||||
// Parameter1 = cid, Parameter2 = access bitmask.
|
// 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()
|
c.mu.RLock()
|
||||||
cs, ok := c.byCID[hdr.Parameter1]
|
cs, ok := c.byCID[hdr.Parameter1]
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
dbg("CA ACCESS_RIGHTS", "cid", hdr.Parameter1, "access", hdr.Parameter2, "found", ok)
|
||||||
if ok {
|
if ok {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
cs.access = hdr.Parameter2
|
cs.access = hdr.Parameter2
|
||||||
|
cs.gotAccess = true
|
||||||
|
var ready chan struct{}
|
||||||
|
if cs.gotChan {
|
||||||
|
ready = cs.readyC
|
||||||
|
}
|
||||||
cs.mu.Unlock()
|
cs.mu.Unlock()
|
||||||
|
if ready != nil {
|
||||||
|
select {
|
||||||
|
case <-ready:
|
||||||
|
// Already closed (guard against duplicate ACCESS_RIGHTS).
|
||||||
|
default:
|
||||||
|
close(ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case proto.CmdEventAdd:
|
case proto.CmdEventAdd:
|
||||||
|
|||||||
+12
-5
@@ -15,6 +15,8 @@ export default function App() {
|
|||||||
const [mode, setMode] = useState<AppMode>('view');
|
const [mode, setMode] = useState<AppMode>('view');
|
||||||
const [wsStatus, setWsStatus] = useState('connecting');
|
const [wsStatus, setWsStatus] = useState('connecting');
|
||||||
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const unsub = wsClient.status.subscribe(setWsStatus);
|
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||||
@@ -36,7 +38,8 @@ export default function App() {
|
|||||||
setMode('edit');
|
setMode('edit');
|
||||||
}
|
}
|
||||||
|
|
||||||
function exitEdit() {
|
function exitEdit(iface: Interface) {
|
||||||
|
setViewTarget(iface);
|
||||||
setMode('view');
|
setMode('view');
|
||||||
setEditTarget(null);
|
setEditTarget(null);
|
||||||
}
|
}
|
||||||
@@ -46,10 +49,14 @@ export default function App() {
|
|||||||
{wsStatus === 'disconnected' && (
|
{wsStatus === 'disconnected' && (
|
||||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||||
)}
|
)}
|
||||||
{mode === 'view'
|
{/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed.
|
||||||
? <ViewMode onEdit={enterEdit} />
|
EditMode is conditionally rendered; it resets from props each time anyway. */}
|
||||||
: <EditMode initial={editTarget} onDone={exitEdit} />
|
<div style={`display:${mode === 'view' ? 'contents' : 'none'}`}>
|
||||||
}
|
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} />
|
||||||
|
</div>
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<EditMode initial={editTarget} onDone={exitEdit} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -14,6 +14,7 @@ import PlotWidget from './widgets/PlotWidget';
|
|||||||
import ImageWidget from './widgets/ImageWidget';
|
import ImageWidget from './widgets/ImageWidget';
|
||||||
import LinkWidget from './widgets/LinkWidget';
|
import LinkWidget from './widgets/LinkWidget';
|
||||||
import ContextMenu from './ContextMenu';
|
import ContextMenu from './ContextMenu';
|
||||||
|
import InfoPanel from './InfoPanel';
|
||||||
|
|
||||||
const COMPONENTS: Record<string, any> = {
|
const COMPONENTS: Record<string, any> = {
|
||||||
textview: TextView,
|
textview: TextView,
|
||||||
@@ -41,12 +42,14 @@ interface Props {
|
|||||||
iface: Interface | null;
|
iface: Interface | null;
|
||||||
onNavigate?: (interfaceId: string) => void;
|
onNavigate?: (interfaceId: string) => void;
|
||||||
timeRange?: { start: string; end: string } | null;
|
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>({
|
const [ctxMenu, setCtxMenu] = useState<CtxState>({
|
||||||
visible: false, x: 0, y: 0, signal: null,
|
visible: false, x: 0, y: 0, signal: null,
|
||||||
});
|
});
|
||||||
|
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
|
||||||
|
|
||||||
function onCtxMenu(e: MouseEvent, widget: Widget) {
|
function onCtxMenu(e: MouseEvent, widget: Widget) {
|
||||||
e.preventDefault();
|
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 });
|
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) {
|
if (!iface) {
|
||||||
return (
|
return (
|
||||||
<div class="canvas-container">
|
<div class="canvas-container">
|
||||||
@@ -90,10 +105,17 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
{...ctxMenu}
|
{...ctxMenu}
|
||||||
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
|
onClose={closeCtxMenu}
|
||||||
|
onInfo={handleInfo}
|
||||||
|
onPlot={onPlot ? handlePlot : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{infoSignal && (
|
||||||
|
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -10,9 +10,11 @@ interface Props {
|
|||||||
y: number;
|
y: number;
|
||||||
signal: SignalRef | null;
|
signal: SignalRef | null;
|
||||||
onClose: () => void;
|
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 [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -36,12 +38,20 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
|||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
function copySignalName() {
|
function copySignalName() {
|
||||||
if (signal) {
|
if (signal) navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||||
navigator.clipboard.writeText(signal.name).catch(() => {});
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openInfo() {
|
||||||
|
onClose();
|
||||||
|
onInfo?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPlot() {
|
||||||
|
onClose();
|
||||||
|
onPlot?.();
|
||||||
|
}
|
||||||
|
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
// Phase 5+: export CSV data
|
// Phase 5+: export CSV data
|
||||||
onClose();
|
onClose();
|
||||||
@@ -62,6 +72,9 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="ctx-divider" />
|
<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={copySignalName}>Copy signal name</button>
|
||||||
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import HelpModal from './HelpModal';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initial: Interface | null;
|
initial: Interface | null;
|
||||||
onDone: () => void;
|
onDone: (iface: Interface) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blankInterface(): Interface {
|
function blankInterface(): Interface {
|
||||||
@@ -229,7 +229,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
|
|
||||||
function handleLeave() {
|
function handleLeave() {
|
||||||
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||||
onDone();
|
onDone(iface);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
// ── 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-11
@@ -37,6 +37,8 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
|
|||||||
|
|
||||||
// Widgets that show units from signal metadata (can be overridden)
|
// Widgets that show units from signal metadata (can be overridden)
|
||||||
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
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)
|
// Widgets with a signal-based label (can be overridden)
|
||||||
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
|
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
|
||||||
// Widgets with multiple signals
|
// Widgets with multiple signals
|
||||||
@@ -154,24 +156,34 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
signal widgets use it as header label */}
|
signal widgets use it as header label */}
|
||||||
{w.type !== 'image' && w.type !== 'link' && (
|
{w.type !== 'image' && w.type !== 'link' && (
|
||||||
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
||||||
<TextInput
|
{LABEL_WIDGETS.has(w.type) ? (
|
||||||
value={w.options['label'] ?? ''}
|
<div class="prop-field-col">
|
||||||
onCommit={(v) => setOpt('label', v)}
|
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||||
/>
|
|
||||||
{LABEL_WIDGETS.has(w.type) && (
|
|
||||||
<span class="prop-hint">Empty = signal name</span>
|
<span class="prop-hint">Empty = signal name</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unit override for signal widgets */}
|
{/* Unit override for signal widgets */}
|
||||||
{UNIT_WIDGETS.has(w.type) && (
|
{UNIT_WIDGETS.has(w.type) && (
|
||||||
<Field label="Unit override">
|
<Field label="Unit">
|
||||||
<TextInput
|
<div class="prop-field-col">
|
||||||
value={w.options['unit'] ?? ''}
|
<TextInput value={w.options['unit'] ?? ''} onCommit={(v) => setOpt('unit', v)} />
|
||||||
onCommit={(v) => setOpt('unit', v)}
|
<span class="prop-hint">Empty = from meta · "none" = hide</span>
|
||||||
/>
|
</div>
|
||||||
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
|
</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>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -271,6 +283,12 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
<Field label="Y max">
|
<Field label="Y max">
|
||||||
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
|
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
|
||||||
</Field>
|
</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">
|
<Field label="Legend">
|
||||||
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
|
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
|
||||||
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
|
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 { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
import type { SignalRef } from './lib/types';
|
import type { SignalRef } from './lib/types';
|
||||||
import SyntheticWizard from './SyntheticWizard';
|
import SyntheticWizard from './SyntheticWizard';
|
||||||
|
import SyntheticEditor from './SyntheticEditor';
|
||||||
|
|
||||||
interface SignalInfo {
|
interface SignalInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -47,6 +48,7 @@ export default function SignalTree({ onDragStart }: Props) {
|
|||||||
const [cfAvailable, setCfAvailable] = useState(false);
|
const [cfAvailable, setCfAvailable] = useState(false);
|
||||||
const [cfSearching, setCfSearching] = useState(false);
|
const [cfSearching, setCfSearching] = useState(false);
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
||||||
const csvRef = useRef<HTMLInputElement>(null);
|
const csvRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
@@ -284,6 +286,14 @@ export default function SignalTree({ onDragStart }: Props) {
|
|||||||
>
|
>
|
||||||
<span class="signal-name">{sig.name}</span>
|
<span class="signal-name">{sig.name}</span>
|
||||||
{sig.unit && <span class="signal-unit">{sig.unit}</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 && (
|
{isCustom && (
|
||||||
<button
|
<button
|
||||||
class="icon-btn signal-remove-btn"
|
class="icon-btn signal-remove-btn"
|
||||||
@@ -330,6 +340,14 @@ export default function SignalTree({ onDragStart }: Props) {
|
|||||||
onCreated={loadGroups}
|
onCreated={loadGroups}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editSynthetic && (
|
||||||
|
<SyntheticEditor
|
||||||
|
name={editSynthetic}
|
||||||
|
onClose={() => setEditSynthetic(null)}
|
||||||
|
onSaved={loadGroups}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</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[] }> = [
|
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'factor', type: 'number', default: '1' }] },
|
{ 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: '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: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] },
|
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', 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: '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: '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: '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) {
|
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 { useState, useMemo, useEffect } from 'preact/hooks';
|
||||||
import InterfaceList from './InterfaceList';
|
import InterfaceList from './InterfaceList';
|
||||||
import Canvas from './Canvas';
|
import Canvas from './Canvas';
|
||||||
|
import PlotPanel from './PlotPanel';
|
||||||
|
import type { PlotSignal } from './PlotPanel';
|
||||||
import { wsClient } from './lib/ws';
|
import { wsClient } from './lib/ws';
|
||||||
import { parseInterface } from './lib/xml';
|
import { parseInterface } from './lib/xml';
|
||||||
import type { Interface } from './lib/types';
|
import type { Interface, SignalRef } from './lib/types';
|
||||||
import ContextualHelp from './ContextualHelp';
|
import ContextualHelp from './ContextualHelp';
|
||||||
import HelpModal from './HelpModal';
|
import HelpModal from './HelpModal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEdit?: (iface?: Interface) => void;
|
onEdit?: (iface?: Interface) => void;
|
||||||
|
initialInterface?: Interface | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
|
/** 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())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewMode({ onEdit }: Props) {
|
const PLOT_COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null);
|
|
||||||
|
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 [parseError, setParseError] = useState<string | null>(null);
|
||||||
const [wsStatus, setWsStatus] = useState('connecting');
|
const [wsStatus, setWsStatus] = useState('connecting');
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [helpSection, setHelpSection] = useState('start');
|
const [helpSection, setHelpSection] = useState('start');
|
||||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||||
|
const [viewTab, setViewTab] = useState<ViewTab>('hmi');
|
||||||
|
const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]);
|
||||||
|
|
||||||
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
||||||
|
|
||||||
@@ -39,6 +48,11 @@ export default function ViewMode({ onEdit }: Props) {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// When returning from edit mode, show the edited interface
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialInterface) setCurrentInterface(initialInterface);
|
||||||
|
}, [initialInterface]);
|
||||||
|
|
||||||
function handleLoad(xml: string) {
|
function handleLoad(xml: string) {
|
||||||
try {
|
try {
|
||||||
setParseError(null);
|
setParseError(null);
|
||||||
@@ -81,6 +95,20 @@ export default function ViewMode({ onEdit }: Props) {
|
|||||||
setTimeRange(null);
|
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;
|
const isLive = timeRange === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,12 +200,43 @@ export default function ViewMode({ onEdit }: Props) {
|
|||||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
handleLoad(await res.text());
|
handleLoad(await res.text());
|
||||||
|
setViewTab('hmi');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : 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>
|
</div>
|
||||||
|
|
||||||
{showHelp && (
|
{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;
|
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 ─────────────────────────────────────────────────────── */
|
/* ── Properties Pane ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.props-panel {
|
.props-panel {
|
||||||
@@ -1433,6 +1505,15 @@ body {
|
|||||||
|
|
||||||
.prop-input-num { width: 70px; flex: none; }
|
.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 {
|
.prop-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #0f1117;
|
background: #0f1117;
|
||||||
@@ -2073,3 +2154,286 @@ kbd {
|
|||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
line-height: 1.2;
|
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 { h } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { formatValue } from '../lib/format';
|
||||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
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 minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||||
const quality = sv.quality;
|
const quality = sv.quality;
|
||||||
|
const fmt = widget.options['format'] ?? '';
|
||||||
|
|
||||||
const rawV = sv.value;
|
const rawV = sv.value;
|
||||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||||
@@ -41,7 +43,7 @@ export default function BarH({ widget, onContextMenu }: Props) {
|
|||||||
|
|
||||||
function displayValue(): string {
|
function displayValue(): string {
|
||||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
return formatValue(rawValue, fmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillPercent(): number {
|
function fillPercent(): number {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { formatValue } from '../lib/format';
|
||||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
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 minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||||
const quality = sv.quality;
|
const quality = sv.quality;
|
||||||
|
const fmt = widget.options['format'] ?? '';
|
||||||
|
|
||||||
const rawV = sv.value;
|
const rawV = sv.value;
|
||||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||||
@@ -41,7 +43,7 @@ export default function BarV({ widget, onContextMenu }: Props) {
|
|||||||
|
|
||||||
function displayValue(): string {
|
function displayValue(): string {
|
||||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
return formatValue(rawValue, fmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillPercent(): number {
|
function fillPercent(): number {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { formatValue } from '../lib/format';
|
||||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
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 thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null;
|
||||||
|
|
||||||
const quality = sv.quality;
|
const quality = sv.quality;
|
||||||
|
const fmt = widget.options['format'] ?? '';
|
||||||
const rawV = sv.value;
|
const rawV = sv.value;
|
||||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||||
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||||
|
|
||||||
function displayValue(): string {
|
function displayValue(): string {
|
||||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
return formatValue(rawValue, fmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillColor(): string {
|
function fillColor(): string {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import uPlot from 'uplot';
|
|||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { getSignalStore } from '../lib/stores';
|
import { getSignalStore } from '../lib/stores';
|
||||||
import { wsClient } from '../lib/ws';
|
import { wsClient } from '../lib/ws';
|
||||||
|
import { formatValue } from '../lib/format';
|
||||||
import type { Widget, SignalRef } from '../lib/types';
|
import type { Widget, SignalRef } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -82,6 +83,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
||||||
const histValues: number[][] = signals.map(() => []);
|
const histValues: number[][] = signals.map(() => []);
|
||||||
const unsubs: (() => void)[] = [];
|
const unsubs: (() => void)[] = [];
|
||||||
|
const fmt = widget.options['format'] ?? '';
|
||||||
|
|
||||||
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
||||||
let uplot: uPlot | null = null;
|
let uplot: uPlot | null = null;
|
||||||
@@ -110,6 +112,37 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
] as uPlot.AlignedData;
|
] 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 ────────────────────────────────────────────────────────────
|
// ── ECharts ────────────────────────────────────────────────────────────
|
||||||
let echart: echarts.EChartsType | null = null;
|
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 (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
|
||||||
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
|
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
|
||||||
|
|
||||||
if (plotType === 'timeseries') {
|
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 {
|
|
||||||
echart = echarts.init(el);
|
echart = echarts.init(el);
|
||||||
echart.setOption(echartsOption());
|
echart.setOption(echartsOption());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Historical mode (timeseries only) ──────────────────────────────────
|
// ── 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') {
|
if (timeRange && plotType === 'timeseries') {
|
||||||
setHistStatus('loading');
|
setHistStatus('loading');
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -272,18 +283,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0);
|
const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0);
|
||||||
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
|
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(() => {
|
}).catch(() => {
|
||||||
if (!cancelled) setHistStatus('error');
|
if (!cancelled) setHistStatus('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (uplot) uplot.destroy();
|
uplot?.destroy();
|
||||||
if (echart) echart.dispose();
|
echart?.dispose();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Live mode: init uPlot immediately ──────────────────────────────────
|
||||||
|
if (plotType === 'timeseries') {
|
||||||
|
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Live streaming mode ────────────────────────────────────────────────
|
// ── Live streaming mode ────────────────────────────────────────────────
|
||||||
setHistStatus('idle');
|
setHistStatus('idle');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { formatValue } from '../lib/format';
|
||||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
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 rawUnit = widget.options['unit'];
|
||||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||||
const quality = sv.quality;
|
const quality = sv.quality;
|
||||||
|
const fmt = widget.options['format'] ?? '';
|
||||||
|
|
||||||
function displayValue(): string {
|
function displayValue(): string {
|
||||||
if (!sv || sv.value === null || sv.value === undefined) return '---';
|
if (!sv || sv.value === null || sv.value === undefined) return '---';
|
||||||
const v = sv.value;
|
const v = sv.value;
|
||||||
if (typeof v === 'number') {
|
if (typeof v === 'number') return formatValue(v, fmt);
|
||||||
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
|
||||||
}
|
|
||||||
return String(v);
|
return String(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user