Improving all side of app

This commit is contained in:
Martino Ferrari
2026-06-20 14:28:28 +02:00
parent 901b87d407
commit 446de7f1ee
33 changed files with 1758 additions and 389 deletions
+2
View File
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import ControlDialogs from './ControlDialogs';
import { applyZoom, getStoredZoom } from './ZoomControl';
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
import type { Interface, Me } from './lib/types';
@@ -69,6 +70,7 @@ export default function App() {
return (
<AuthContext.Provider value={me}>
<div class="app-root">
<ControlDialogs />
{wsStatus === 'disconnected' && (
<div class="connection-banner">WebSocket disconnected reconnecting</div>
)}
+60
View File
@@ -0,0 +1,60 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import { controlDialogs, dismissControlDialog, type ControlDialog } from './lib/controldialogs';
// Renders dialogs pushed by server-side control-logic action.dialog nodes over
// the WebSocket. Mounted once globally (App) since these are addressed to the
// connected user, independent of which panel is open. info/error dialogs are
// acknowledgements; input dialogs send a numeric response back to the server.
export default function ControlDialogs() {
const [reqs, setReqs] = useState<ControlDialog[]>([]);
useEffect(() => controlDialogs.subscribe(setReqs), []);
if (reqs.length === 0) return null;
return <Fragment>{reqs.map(r => <ControlDialogModal key={r.id} req={r} />)}</Fragment>;
}
function ControlDialogModal({ req }: { req: ControlDialog; key?: string }) {
const isInput = req.kind === 'input';
const [val, setVal] = useState('');
function ok() {
if (isInput) {
const n = parseFloat(val);
wsClient.sendDialogResponse(req.id, Number.isFinite(n) ? n : null);
}
dismissControlDialog(req.id);
}
function cancel() {
if (isInput) wsClient.sendDialogResponse(req.id, null);
dismissControlDialog(req.id);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); ok(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}
return (
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
<div class="logic-dialog-header">
{req.title || (req.kind === 'error' ? 'Error' : isInput ? 'Input required' : 'Info')}
</div>
{req.message && <div class="logic-dialog-message">{req.message}</div>}
{isInput && (
<div class="logic-dialog-field">
<input class="prop-input logic-dialog-input" type="number"
autoFocus value={val}
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
onKeyDown={onKey} />
</div>
)}
<div class="logic-dialog-actions">
{isInput && <button class="panel-btn" onClick={cancel}>Cancel</button>}
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
</div>
</div>
</div>
);
}
+99 -63
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr';
@@ -18,7 +18,8 @@ type CLNodeKind =
| 'action.write'
| 'action.delay'
| 'action.log'
| 'action.lua';
| 'action.lua'
| 'action.dialog';
interface CLNode {
id: string;
@@ -55,6 +56,11 @@ const NODE_W = 11.5 * REM;
const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM;
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
@@ -71,6 +77,7 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
];
const KIND_LABEL: Record<CLNodeKind, string> = {
@@ -86,6 +93,7 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
'action.delay': 'Delay',
'action.log': 'Log',
'action.lua': 'Lua script',
'action.dialog': 'Dialog',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -117,11 +125,11 @@ function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function inAnchor(n: CLNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: CLNode, port: string) {
const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
@@ -384,6 +392,15 @@ function FlowEditor({ graph, onChange }: {
return dsSignals[ds] ?? [];
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
@@ -603,19 +620,13 @@ function FlowEditor({ graph, onChange }: {
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dataSources}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
@@ -691,7 +702,7 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
@@ -715,50 +726,30 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target {ds === 'local' ? 'variable' : ds === 'srv' ? 'server variable' : 'signal'}</label>
{(ds === 'local' || ds === 'srv') ? (
<Fragment>
<input class="prop-input" list={`clvars-${selected.id}`} value={name}
placeholder={ds === 'srv' ? 'server variable name (read by panels)' : 'local variable name'}
onInput={(e) => patchParams(selected.id, { target: `${ds}:${(e.target as HTMLInputElement).value}` })} />
<datalist id={`clvars-${selected.id}`}>
{signalOptions(ds).map(o => <option key={o} value={o} />)}
</datalist>
<p class="hint">{ds === 'srv'
? 'Server variables are visible to interface panels (read-only unless the user has control-logic access).'
: 'Local variables live only inside this graph and reset on reload.'}</p>
</Fragment>
) : (
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
)}
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
);
})()}
{selected.kind === 'action.write' && (
<Fragment>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Pick a signal, or type <code>local:name</code> for a graph-local var
or <code>srv:name</code> for a server variable that panels can read.</p>
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
)}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
@@ -779,7 +770,7 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the server log — handy for debugging a flow." />
</Fragment>
)}
@@ -797,6 +788,54 @@ function FlowEditor({ graph, onChange }: {
</div>
)}
{selected.kind === 'action.dialog' && (
<Fragment>
<div class="wizard-field">
<label>Type</label>
<select class="prop-input" value={selected.params.kind ?? 'info'}
onChange={(e) => patchParams(selected.id, { kind: (e.target as HTMLSelectElement).value })}>
<option value="info">Info</option>
<option value="error">Error</option>
<option value="input">Input (ask for a value)</option>
</select>
</div>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="dialog title"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="shown to the user"
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
{selected.params.kind === 'input' && (
<div class="wizard-field">
<label>Response variable</label>
<input class="prop-input" value={selected.params.target ?? ''}
placeholder="srv:approved"
onInput={(e) => patchParams(selected.id, { target: (e.target as HTMLInputElement).value })} />
<p class="hint">The user's number is written here (e.g. <code>srv:approved</code>); read it on a later activation.</p>
</div>
)}
<div class="wizard-field">
<label>Users (optional)</label>
<input class="prop-input" value={selected.params.users ?? ''}
placeholder="alice, bob"
onInput={(e) => patchParams(selected.id, { users: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Groups (optional)</label>
<input class="prop-input" value={selected.params.groups ?? ''}
placeholder="operators"
onInput={(e) => patchParams(selected.id, { groups: (e.target as HTMLInputElement).value })} />
<p class="hint">Comma-separated. Leave both empty to show the dialog to every connected user.</p>
</div>
</Fragment>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
@@ -818,17 +857,15 @@ function FlowEditor({ graph, onChange }: {
}
}
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
@@ -838,11 +875,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
placeholder="+ insert signal"
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
</div>
<p class="hint">{hint}</p>
</div>
@@ -865,6 +900,7 @@ function nodeSummary(n: CLNode): string {
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.lua': return 'Lua script';
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
default: return '';
}
}
+54 -61
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
@@ -37,6 +37,11 @@ const NODE_W = 11.5 * REM; // node width
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry {
kind: LogicNodeKind;
@@ -114,11 +119,11 @@ function nodeHeight(kind: LogicNodeKind): number {
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
// Port anchors in canvas coordinates.
function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: LogicNode, port: string) {
const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
@@ -180,6 +185,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
return dsSignals[ds] ?? [];
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
@@ -520,19 +534,13 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
)}
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
@@ -573,7 +581,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
@@ -597,36 +605,29 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment>
);
})()}
{selected.kind === 'action.write' && (
<Fragment>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment>
)}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
@@ -647,7 +648,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
@@ -683,7 +684,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the browser console — handy for debugging a flow." />
</Fragment>
)}
@@ -747,7 +748,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
</Fragment>
)}
@@ -768,7 +769,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</Fragment>
@@ -796,17 +797,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
}
// Expression input with a signal-insert helper and live validation.
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
@@ -816,11 +815,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
placeholder="+ insert signal"
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
</div>
<p class="hint">{hint}</p>
</div>
@@ -934,13 +931,12 @@ function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
@@ -953,7 +949,6 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
@@ -972,11 +967,9 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
<SignalPicker value={f.target ?? ''} options={allSignals}
onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
onChange={(ref) => patch(i, { target: ref })} />
</div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)"
+113
View File
@@ -0,0 +1,113 @@
import { h } from 'preact';
import { useState, useMemo } from 'preact/hooks';
// A single fuzzy-finder input for picking a signal as one "ds:name" reference,
// replacing the old stacked "Data source" + "Signal" select pair. The caller
// supplies a flattened option list (every ds:name it knows about) and, via
// onOpen, a hook to lazily load signals for all data sources when the dropdown
// is first opened. When allowFreeform is set, typing a value that matches no
// option (e.g. a new srv:/local: variable) can still be committed.
export interface SignalOption { ds: string; name: string; }
export default function SignalPicker({
value,
options,
onChange,
onOpen,
placeholder = 'Search signals…',
allowFreeform = false,
}: {
value: string; // current "ds:name" (or "")
options: SignalOption[];
onChange: (ref: string) => void;
onOpen?: () => void;
placeholder?: string;
allowFreeform?: boolean;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean);
const scored = options
.map(o => ({ o, label: `${o.ds}:${o.name}` }))
.filter(({ label }) => {
const l = label.toLowerCase();
return tokens.every(t => l.includes(t));
});
// Prefer matches where the signal name (not the ds prefix) leads.
const f = filter.toLowerCase();
scored.sort((a, b) => {
const an = a.o.name.toLowerCase().startsWith(f) ? 0 : 1;
const bn = b.o.name.toLowerCase().startsWith(f) ? 0 : 1;
if (an !== bn) return an - bn;
return a.label.localeCompare(b.label);
});
return scored.slice(0, 200);
}, [options, filter]);
function openDropdown() {
setOpen(true);
setFilter('');
onOpen?.();
}
function commit(ref: string) {
onChange(ref);
setOpen(false);
setFilter('');
}
// A freeform entry is offered when the typed text looks like a usable
// reference and is not already an exact option.
const freeform = allowFreeform && filter.trim() !== ''
&& !filtered.some(({ label }) => label === filter.trim());
return (
<div class="search-select">
<div class="search-select-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
{value || <span class="search-select-placeholder">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Type to search… (ds:name)"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
if (filtered.length > 0) commit(`${filtered[0].o.ds}:${filtered[0].o.name}`);
else if (freeform) commit(filter.trim());
}
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
}}
/>
<div class="search-select-list">
{freeform && (
<div class="search-select-item" onClick={() => commit(filter.trim())}>
Use {filter.trim()}
</div>
)}
{filtered.length === 0 && !freeform && <div class="search-select-item hint">No matches</div>}
{filtered.map(({ o, label }) => (
<div
key={label}
class={`search-select-item${label === value ? ' search-select-item-selected' : ''}`}
onClick={() => commit(`${o.ds}:${o.name}`)}
>
{label}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
+13 -18
View File
@@ -4,6 +4,7 @@ import type { SignalRef, StateVar } from './lib/types';
import SyntheticGraphEditor from './SyntheticGraphEditor';
import GroupsTree from './GroupsTree';
import ChannelFinderModal from './ChannelFinderModal';
import SignalPicker from './SignalPicker';
type TreeTab = 'sources' | 'groups';
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
@@ -523,24 +524,18 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
</div>
<div class="wizard-body">
<div class="wizard-field">
<label>Data Source</label>
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
<option key={ds} value={ds}>{ds}</option>
))}
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
</select>
</div>
<div class="wizard-field">
<label>Signal / PV Name</label>
<input
class="prop-input"
autoFocus
placeholder="e.g. MY:PV:NAME"
value={manualName}
onInput={e => setManualName((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
/>
<label>Signal</label>
<SignalPicker
value={manualName ? `${manualDs}:${manualName}` : ''}
options={allSignals}
allowFreeform
placeholder="Search or type ds:NAME…"
onChange={(ref) => {
const i = ref.indexOf(':');
if (i < 0) { setManualDs(ref); setManualName(''); }
else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
}} />
<p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
</div>
</div>
<div class="wizard-footer">
+319 -119
View File
@@ -1,8 +1,8 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
@@ -19,41 +19,51 @@ interface Props {
}
// ── Node-type catalogue ──────────────────────────────────────────────────────
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
// pipeline receives — see the compile() note below.
// Mirrors the backend dsp node set (internal/dsp/nodes.go). `arity` describes how
// many input ports an op exposes:
// fixed n — exactly n inputs (gain=1, subtract/divide=2, …)
// min n — at least n inputs; one spare port appears past the wired count so the
// user can keep adding (add/multiply)
// named — the user names each input (expr/lua); ports follow params.vars
type InArity = { kind: 'fixed'; n: number } | { kind: 'min'; n: number } | { kind: 'named' };
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
interface OpDef { type: string; label: string; params: NodeParam[]; }
interface OpDef { type: string; label: string; arity: InArity; params: NodeParam[]; }
const OPS: OpDef[] = [
{ 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: 'add', label: 'Add (Σ inputs)', params: [] },
{ type: 'subtract', label: 'Subtract (ab)', params: [] },
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
{ 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: 'lowpass', label: 'Low-pass', params: [
{ type: 'gain', label: 'Gain', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'add', label: 'Add (Σ inputs)', arity: { kind: 'min', n: 2 }, params: [] },
{ type: 'subtract', label: 'Subtract (ab)', arity: { kind: 'fixed', n: 2 }, params: [] },
{ type: 'multiply', label: 'Multiply (Π)', arity: { kind: 'min', n: 2 }, params: [] },
{ type: 'divide', label: 'Divide (a÷b)', arity: { kind: 'fixed', n: 2 }, params: [] },
{ type: 'moving_average', label: 'Moving Average', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', params: [
{ type: 'derivative', label: 'Derivative', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'integrate', label: 'Integrate', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'clamp', label: 'Clamp', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
{ label: 'High output', key: 'high', type: 'number', default: '1' },
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
]},
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
{ type: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] },
{ type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] },
];
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
function opArity(type?: string): InArity { return OP_BY_TYPE.get(type ?? '')?.arity ?? { kind: 'fixed', n: 1 }; }
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
// Default input names a,b,c,… for named (expr/lua) nodes.
function letters(n: number): string[] {
return Array.from({ length: Math.max(1, n) }, (_, i) => String.fromCharCode(97 + i));
}
// ── Graph model (UI only — compiled to SynthGraph on save) ──────────────────
type NodeKind = 'source' | 'op' | 'output';
interface GNode {
id: string;
@@ -63,9 +73,10 @@ interface GNode {
ds?: string; // source
signal?: string; // source
op?: string; // op type
params?: Record<string, any>; // op
params?: Record<string, any>; // op (named ops carry params.vars: string[])
}
interface GWire { from: string; to: string; }
// A wire terminates on a specific input port (index) of the target node.
interface GWire { from: string; to: string; toPort: number; }
interface Graph { nodes: GNode[]; wires: GWire[]; }
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
@@ -74,23 +85,75 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
const PORT_TOP = 1.4 * REM;
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
const PORT_R = 0.375 * REM;
// Ports are absolutely positioned inside the node's padding box, which is offset
// from the node's border-box origin (node.x/node.y) by the node borders: a 3px
// colored accent on top and 1px on the sides. Wire anchors must add the same
// offsets or they land at the top edge of the port circle instead of its center.
const BORDER = 1;
const BORDER_TOP = 3;
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
// Number of input ports a node currently shows.
function inPortCount(n: GNode, wires: GWire[]): number {
if (n.kind === 'source') return 0;
if (n.kind === 'output') return 1;
const ar = opArity(n.op);
if (ar.kind === 'fixed') return ar.n;
if (ar.kind === 'named') return Math.max(1, (n.params?.vars as string[] | undefined)?.length ?? 0);
// min: expose one spare port beyond the highest wired index so the user can add more.
const wired = wires.filter(w => w.to === n.id).length;
return Math.max(ar.n, wired + 1);
}
function inAnchor(n: GNode, port: number) {
return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP + port * PORT_GAP };
}
function outAnchor(n: GNode) { return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function nodeMinHeight(n: GNode, wires: GWire[]): number {
const nIn = Math.max(1, inPortCount(n, wires));
return BORDER_TOP + PORT_TOP + nIn * PORT_GAP;
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// Build an initial graph by laying out an existing SignalDef: sources stacked on
// the left, the linear pipeline as a row of op nodes, output on the right.
// Label shown beside an input port (named ops show their var name).
function portLabel(n: GNode, port: number): string {
if (opArity(n.op).kind === 'named') {
const vars = (n.params?.vars as string[] | undefined) ?? [];
return vars[port] ?? '';
}
return '';
}
// Build an initial graph from an existing SignalDef. Prefer the stored DAG; fall
// back to laying out the legacy linear Inputs+Pipeline form.
function buildInitial(def: SignalDef): Graph {
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
if (def.graph && def.graph.nodes.length > 0) {
const nodes: GNode[] = def.graph.nodes.map((n, i) => ({
id: n.id,
kind: n.kind,
x: n.x ?? (2 + (i % 3) * 12) * REM,
y: n.y ?? (2 + Math.floor(i / 3) * 5) * REM,
ds: n.ds,
signal: n.signal,
op: n.op,
params: n.params ? { ...n.params } : undefined,
}));
const wires: GWire[] = [];
for (const n of def.graph.nodes) {
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
}
return { nodes, wires };
}
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
const inputs = def.inputs && def.inputs.length > 0
? def.inputs
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
const pipeline = def.pipeline ?? [];
@@ -104,57 +167,108 @@ function buildInitial(def: SignalDef): Graph {
});
const opIds = pipeline.map((nd, i) => {
const id = genId();
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
const params: Record<string, any> = { ...nd.params };
// The head op received every source as a,b,c,…; later ops a single input.
if (opArity(nd.type).kind === 'named') params.vars = letters(i === 0 ? srcIds.length : 1);
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params });
return id;
});
const outId = genId();
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
const headId = opIds[0] ?? outId;
srcIds.forEach(s => wires.push({ from: s, to: headId }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
srcIds.forEach((s, i) => wires.push({ from: s, to: headId, toPort: i }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1], toPort: 0 });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId, toPort: 0 });
return { nodes, wires };
}
// Compile the visual graph back into the backend's linear form. The pipeline is
// a single chain ending at the output; the head node receives every source
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
// Compile the visual graph into the backend DAG form. Each op/output node's
// inputs are its wires ordered by target port index.
function compile(g: Graph): SynthGraph {
const output = g.nodes.find(n => n.kind === 'output');
if (!output) return { error: 'missing output node' };
const byId = new Map(g.nodes.map(n => [n.id, n]));
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
const nodes: SynthGraphNode[] = g.nodes.map(n => {
const inputs = g.wires
.filter(w => w.to === n.id)
.slice()
.sort((a, b) => a.toPort - b.toPort)
.map(w => w.from);
if (n.kind === 'source') return { id: n.id, kind: 'source', ds: n.ds, signal: n.signal, x: n.x, y: n.y };
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
});
return { nodes, output: output?.id ?? '' };
}
const chain: GNode[] = [];
const seen = new Set<string>();
let cur: GNode = output;
for (;;) {
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
seen.add(cur.id);
const ups = upstream(cur.id);
const opUps = ups.filter(n => n.kind === 'op');
const srcUps = ups.filter(n => n.kind === 'source');
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
if (opUps.length === 1) {
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
chain.unshift(opUps[0]);
cur = opUps[0];
// Detect a cycle in the graph (Kahn count over input edges).
function hasCycle(g: Graph): boolean {
const indeg = new Map<string, number>();
const succ = new Map<string, string[]>();
for (const n of g.nodes) indeg.set(n.id, 0);
for (const w of g.wires) {
if (!indeg.has(w.to) || !indeg.has(w.from)) continue;
indeg.set(w.to, (indeg.get(w.to) ?? 0) + 1);
succ.set(w.from, [...(succ.get(w.from) ?? []), w.to]);
}
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
let visited = 0;
while (queue.length) {
const id = queue.shift()!;
visited++;
for (const s of succ.get(id) ?? []) {
indeg.set(s, (indeg.get(s) ?? 0) - 1);
if ((indeg.get(s) ?? 0) === 0) queue.push(s);
}
}
return visited !== g.nodes.length;
}
// Validate the graph; return a per-node error map plus the first message found.
function validate(g: Graph): { errors: Map<string, string>; first?: string } {
const errors = new Map<string, string>();
const set = (id: string, msg: string) => { if (!errors.has(id)) errors.set(id, msg); };
if (!g.nodes.some(n => n.kind === 'output')) {
return { errors, first: 'missing output node' };
}
for (const n of g.nodes) {
const ports = new Set(g.wires.filter(w => w.to === n.id).map(w => w.toPort));
if (n.kind === 'source') {
if (!n.ds || !n.signal) set(n.id, 'pick a data source and signal');
continue;
}
// Reached the head of the chain: its source upstreams become the inputs.
if (srcUps.length === 0) {
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
if (n.kind === 'output') {
if (!ports.has(0)) set(n.id, 'connect a value to the output');
continue;
}
const ar = opArity(n.op);
if (ar.kind === 'fixed' || ar.kind === 'named') {
const cnt = ar.kind === 'fixed' ? ar.n : ((n.params?.vars as string[] | undefined)?.length ?? 0);
if (cnt === 0) { set(n.id, 'add at least one named input'); continue; }
for (let i = 0; i < cnt; i++) {
if (!ports.has(i)) { set(n.id, 'all inputs must be connected'); break; }
}
} else {
// min: needs ≥ ar.n contiguous inputs starting at port 0.
const cnt = ports.size;
if (cnt < ar.n) { set(n.id, `needs at least ${ar.n} inputs`); continue; }
for (let i = 0; i < cnt; i++) {
if (!ports.has(i)) { set(n.id, 'inputs have a gap — reconnect them'); break; }
}
}
const inputs = srcUps
.slice()
.sort((a, b) => a.y - b.y)
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
return { inputs, pipeline };
}
let first: string | undefined;
if (hasCycle(g)) first = 'the graph contains a cycle';
if (!first) {
for (const n of g.nodes) {
const m = errors.get(n.id);
if (m) { first = `${n.kind === 'op' ? opLabel(n.op ?? '') : n.kind}: ${m}`; break; }
}
}
return { errors, first };
}
function nodeSummary(n: GNode): string {
@@ -228,6 +342,15 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
} catch {}
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dataSources) for (const name of (dsSignals[ds] ?? [])) out.push({ ds, name });
return out;
}
function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); }
useEffect(() => {
if (create) {
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
@@ -288,6 +411,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
function addOp(op: OpDef, x?: number, y?: number) {
const params: Record<string, any> = {};
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
if (op.arity.kind === 'named') params.vars = ['a', 'b'];
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
@@ -306,6 +430,42 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
wires: graph.wires,
});
}
// Named-input (expr/lua) variable list editing.
function addNamedVar(id: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars.push(letters(vars.length + 1)[vars.length]);
return { ...n, params: { ...n.params, vars } };
}),
wires: graph.wires,
});
}
function renameNamedVar(id: string, idx: number, value: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars[idx] = value;
return { ...n, params: { ...n.params, vars } };
}),
wires: graph.wires,
});
}
function removeNamedVar(id: string, idx: number) {
const nodes = graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars.splice(idx, 1);
return { ...n, params: { ...n.params, vars } };
});
// Drop the wire at the removed port and shift higher ports down by one.
const wires = graph.wires
.filter(w => !(w.to === id && w.toPort === idx))
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
commit({ nodes, wires });
}
function moveNode(id: string, x: number, y: number, record: boolean) {
const g = graphRef.current;
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
@@ -316,19 +476,29 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
if (selected === id) setSelected(null);
}
function addWire(from: string, to: string) {
function addWire(from: string, to: string, toPort: number) {
if (from === to) return;
const fn = graph.nodes.find(n => n.id === from);
const tn = graph.nodes.find(n => n.id === to);
if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return;
if (graph.wires.some(w => w.from === from && w.to === to)) return;
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
// One wire per input port: replace any existing wire on that port.
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
commit({ nodes: graph.nodes, wires: [...wires, { from, to, toPort }] });
}
function deleteWire(idx: number) {
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
if (selectedWire === idx) setSelectedWire(null);
}
// The lowest input port of `target` not yet wired (for node-body drops).
function firstFreePort(target: GNode): number {
const used = new Set(graphRef.current.wires.filter(w => w.to === target.id).map(w => w.toPort));
const cnt = inPortCount(target, graphRef.current.wires);
for (let i = 0; i < cnt; i++) if (!used.has(i)) return i;
return cnt; // all full — append (min-arity nodes grow)
}
// ── Pointer / drag / wire ──────────────────────────────────────────────────
function toCanvas(e: MouseEvent) {
const el = canvasRef.current!;
@@ -350,9 +520,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
pendingRef.current = null;
setPendingWire(null);
}
function finishWire(target: GNode) {
function finishWire(target: GNode, port: number) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
if (cur && target.kind !== 'source') addWire(cur.from, target.id, port);
endWire();
}
@@ -422,12 +592,12 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const byId = new Map(graph.nodes.map(n => [n.id, n]));
const sel = graph.nodes.find(n => n.id === selected) ?? null;
const compiled = compile(graph);
const { errors: nodeErrors, first: validationError } = validate(graph);
async function handleSave() {
if (!def) return;
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
if (compiled.error) { setError(compiled.error); return; }
if (validationError) { setError(validationError); return; }
setSaving(true);
setError('');
try {
@@ -441,9 +611,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const body: any = {
...def,
name: sigName,
inputs: compiled.inputs,
pipeline: compiled.pipeline,
graph: compile(graph),
meta,
inputs: undefined,
pipeline: undefined,
ds: undefined,
signal: undefined,
};
@@ -474,7 +645,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
function nodeClass(n: GNode): string {
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}${err}`;
}
return (
@@ -505,8 +677,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
onClick={() => addOp(op)}>{op.label}</button>
))}
<div class="flow-palette-hint hint">
Wire input signals into the first operation, chain operations, and connect the
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
Wire signals into each operation's input ports, then connect the result
to <b>Output</b>. Formula / Lua nodes name their own inputs.
</div>
</div>
@@ -519,7 +691,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
{graph.wires.map((w, idx) => {
const a = byId.get(w.from); const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a); const p2 = inAnchor(b);
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
@@ -535,35 +707,47 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
})()}
</svg>
{graph.nodes.map(node => (
<div key={node.id}
class={nodeClass(node)}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
{graph.nodes.map(node => {
const nIn = inPortCount(node, graph.wires);
return (
<div key={node.id}
class={nodeClass(node)}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
)}
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{Array.from({ length: nIn }, (_, port) => {
const label = portLabel(node, port);
return (
<Fragment key={port}>
<div class="flow-port flow-port-in" title={label || `Input ${port + 1}`}
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
{label && (
<span class="flow-port-label-in"
style={`top:${PORT_TOP + port * PORT_GAP - 0.6 * REM}px;`}>{label}</span>
)}
</Fragment>
);
})}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{hasInput(node.kind) && (
<div class="flow-port flow-port-in" title="Input"
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
)}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
))}
);
})}
</div>
</div>
@@ -619,16 +803,17 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
{sel?.kind === 'source' && (
<Fragment>
<div class="wizard-section-title">Input signal</div>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
onSelect={(signal) => patchNode(sel.id, { signal })}
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
<SignalPicker
value={sel.signal ? `${sel.ds ?? ''}:${sel.signal}` : ''}
options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => {
const i = ref.indexOf(':');
if (i < 0) patchNode(sel.id, { ds: ref, signal: '' });
else patchNode(sel.id, { ds: ref.slice(0, i), signal: ref.slice(i + 1) });
}} />
</div>
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
@@ -637,8 +822,23 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
{sel?.kind === 'op' && (
<Fragment>
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
{opParamDefs(sel.op ?? '').length === 0 && (
<p class="hint">No parameters this operation transforms its input directly.</p>
{opArity(sel.op).kind === 'named' && (
<div class="wizard-field">
<label>Inputs</label>
{((sel.params?.vars as string[] | undefined) ?? []).map((v, i) => (
<div key={i} class="synth-var-row">
<input class="prop-input" type="text" value={v}
onInput={(e) => renameNamedVar(sel.id, i, (e.target as HTMLInputElement).value)} />
<button class="icon-btn" title="Remove input"
onClick={() => removeNamedVar(sel.id, i)}>✕</button>
</div>
))}
<button class="panel-btn" style="margin-top:0.4rem;" onClick={() => addNamedVar(sel.id)}>+ Input</button>
<p class="hint" style="margin-top:0.4rem;">Use these names in the {sel.op === 'lua' ? 'script' : 'formula'} below.</p>
</div>
)}
{opParamDefs(sel.op ?? '').length === 0 && opArity(sel.op).kind !== 'named' && (
<p class="hint">No parameters — this operation transforms its inputs directly.</p>
)}
{opParamDefs(sel.op ?? '').map(pd => (
<div key={pd.key} class="wizard-field">
@@ -669,9 +869,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
<div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
{!error && validationError && <span class="hint" style="flex:1;">{validationError}</span>}
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!compiled.error}>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!validationError}>
{saving ? 'Saving' : 'Save Signal'}
</button>
</div>
+24
View File
@@ -0,0 +1,24 @@
import { writable } from './store';
// A dialog request pushed by a server-side control-logic action.dialog node and
// delivered over the WebSocket. Unlike panel logic dialogs (lib/logic.ts) these
// originate on the server and are addressed to the connected user by identity.
export interface ControlDialog {
id: string;
kind: 'info' | 'error' | 'input';
title?: string;
message?: string;
}
// Active control-logic dialogs awaiting the user's acknowledgement / input.
export const controlDialogs = writable<ControlDialog[]>([]);
// pushControlDialog is invoked by the WS client on a "dialog" message.
export function pushControlDialog(d: ControlDialog): void {
controlDialogs.update(list => (list.some(x => x.id === d.id) ? list : [...list, d]));
}
// dismissControlDialog removes a dialog once answered or cancelled.
export function dismissControlDialog(id: string): void {
controlDialogs.update(list => list.filter(d => d.id !== id));
}
+22 -1
View File
@@ -275,12 +275,33 @@ export interface PipelineNode {
params: Record<string, any>;
}
// DAG form of a synthetic signal (mirrors backend synthetic.Graph). A node is a
// source (ds:signal root), an op (DSP node with ordered `inputs`), or the single
// output. When present it supersedes the legacy inputs+pipeline linear form.
export type SynthNodeKind = 'source' | 'op' | 'output';
export interface SynthGraphNode {
id: string;
kind: SynthNodeKind;
op?: string;
params?: Record<string, any>;
ds?: string;
signal?: string;
inputs?: string[]; // upstream node ids, in input order
x?: number;
y?: number;
}
export interface SynthGraph {
nodes: SynthGraphNode[];
output: string;
}
export interface SignalDef {
name: string;
ds?: string; // legacy single input
signal?: string; // legacy single input
inputs?: InputRef[];
pipeline: PipelineNode[];
pipeline?: PipelineNode[];
graph?: SynthGraph; // DAG form (preferred when present)
meta: {
unit?: string;
description?: string;
+19
View File
@@ -1,5 +1,6 @@
import { writable, type Readable } from './store';
import { writeLocalState } from './localstate';
import { pushControlDialog } from './controldialogs';
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -163,6 +164,16 @@ class WsClient {
}
break;
}
case 'dialog': {
// Server-side control logic requesting a user notification / input.
pushControlDialog({
id: String(msg.id),
kind: msg.kind === 'error' || msg.kind === 'input' ? msg.kind : 'info',
title: msg.title,
message: msg.message,
});
break;
}
case 'error':
// Resolve any pending history callbacks with empty data on error
if (key && this.histCallbacks.has(key)) {
@@ -270,6 +281,14 @@ class WsClient {
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
}
/**
* Answer a control-logic input dialog. `value === null` cancels (dismisses)
* the dialog without writing its target server variable.
*/
sendDialogResponse(id: string, value: number | null): void {
this._send({ type: 'dialogResponse', id, value });
}
}
// Singleton instance
+20
View File
@@ -1263,6 +1263,8 @@ body {
.flow-node-flow { border-top: 3px solid #10b981; }
.flow-node-action { border-top: 3px solid #3b82f6; }
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
.flow-node-error { border-color: #ef4444; box-shadow: 0 0 0 2px #ef444455; }
.flow-node-error.flow-node-selected { box-shadow: 0 0 0 2px #ef4444aa; }
.flow-node-header {
display: flex;
@@ -1309,6 +1311,24 @@ body {
pointer-events: none;
}
/* Named input ports (Formula/Lua) show their variable name beside the port. */
.flow-port-label-in {
position: absolute;
left: 0.55rem;
font-size: 0.6rem;
color: #94a3b8;
pointer-events: none;
}
/* Named-input editor row in the inspector */
.synth-var-row {
display: flex;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.3rem;
}
.synth-var-row > input { flex: 1; min-width: 0; }
/* Expression field: signal-insert helper row */
.flow-expr-insert {
display: flex;
+27 -1
View File
@@ -90,6 +90,17 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// ── uPlot (timeseries) ──────────────────────────────────────────────────
let uplot: uPlot | null = null;
// Newest sample timestamp across all series (seconds). Anchoring the live
// x-axis to this — rather than wall-clock Date.now() — avoids client/server
// clock skew clipping the latest point, and keeps the right edge on real data.
function latestSampleTs(): number {
let m = -Infinity;
for (const b of buffers) {
if (b.timestamps.length) m = Math.max(m, b.timestamps[b.timestamps.length - 1]);
}
return isFinite(m) ? m : Date.now() / 1000;
}
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]];
@@ -165,7 +176,22 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
yAxis,
],
scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } },
scales: {
x: {
time: true,
// Live mode with a finite window: pin the x-axis to a rolling
// [newest window, newest] span so a single old/outlier sample
// can't stretch the axis and compress real points to the right.
// Historical mode (timeRange) keeps uPlot's data-driven auto-range.
...(!timeRange && timeWindow > 0
? { range: (): [number, number] => {
const r = latestSampleTs();
return [r - timeWindow, r];
} }
: {}),
},
y: { ...scaleY, range: scaleYRange },
},
};
}