working epics ioc and tested

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