Files
uopi/web/src/LogicEditor.tsx
T
2026-06-20 14:28:28 +02:00

1096 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':').
function splitRef(ref: string): { ds: string; name: string } {
const i = ref.indexOf(':');
if (i < 0) return { ds: '', name: '' };
return { ds: ref.slice(0, i), name: ref.slice(i + 1) };
}
interface Props {
graph: LogicGraph;
onChange: (graph: LogicGraph) => void;
// The panel's widgets, offered as targets for the Widget-control action.
widgets?: Widget[];
// Panel-local state variables, offered as quick write targets / signals.
statevars?: StateVar[];
// Edit local state variables directly from the logic editor (the layout-side
// SignalTree is hidden while the logic tab is open).
onStateVarsChange?: (vars: StateVar[]) => void;
}
// Visual geometry of a node block. Expressed in rem (relative to the root font
// size) so the whole flow editor scales coherently on high-DPI screens / when
// the base font is enlarged for accessibility, instead of staying pixel-tiny.
const REM = (() => {
if (typeof document === 'undefined') return 16;
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
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;
label: string;
params: Record<string, string>;
}
const PALETTE: PaletteEntry[] = [
{ kind: 'trigger.button', label: 'Button', params: { name: 'action' } },
{ kind: 'trigger.threshold', label: 'Threshold', params: { signal: '', op: '>', value: '0' } },
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
{ kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } },
{ kind: 'trigger.start', label: 'On open', params: {} },
{ kind: 'trigger.stop', label: 'On close', params: {} },
{ kind: 'gate.and', label: 'AND gate', params: {} },
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
{ kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"data","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.widget', label: 'Widget control', params: { widget: '', op: 'disable' } },
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
{ kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } },
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
];
const KIND_LABEL: Record<LogicNodeKind, string> = {
'trigger.button': 'Button',
'trigger.threshold': 'Threshold',
'trigger.change': 'On change',
'trigger.timer': 'Timer',
'trigger.loop': 'Panel loop',
'trigger.start': 'On open',
'trigger.stop': 'On close',
'gate.and': 'AND gate',
'flow.if': 'If / else',
'flow.loop': 'Loop',
'action.write': 'Write',
'action.delay': 'Delay',
'action.accumulate': 'Accumulate',
'action.export': 'Export CSV',
'action.clear': 'Clear array',
'action.log': 'Log',
'action.widget': 'Widget control',
'action.dialog.info': 'Info dialog',
'action.dialog.error': 'Error dialog',
'action.dialog.setpoint': 'Set-point dialog',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
function isTrigger(kind: LogicNodeKind): boolean { return kind.startsWith('trigger.'); }
function category(kind: LogicNodeKind): 'trigger' | 'gate' | 'flow' | 'action' {
if (kind.startsWith('trigger.')) return 'trigger';
if (kind.startsWith('gate.')) return 'gate';
if (kind.startsWith('flow.')) return 'flow';
return 'action';
}
function hasInput(kind: LogicNodeKind): boolean { return !isTrigger(kind); }
interface Port { id: string; label: string; }
function outputs(kind: LogicNodeKind): Port[] {
if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }];
if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }];
return [{ id: 'out', label: '' }];
}
function nodeHeight(kind: LogicNodeKind): number {
return PORT_TOP + outputs(kind).length * PORT_GAP;
}
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 + 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 - 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);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
export default function LogicEditor({ graph, onChange, widgets = [], statevars, onStateVarsChange }: Props) {
const nodes = graph.nodes;
const wires = graph.wires;
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
// Undo/redo history + clipboard, scoped to the logic graph (separate from
// EditMode's widget/layout history). `graphRef` always holds the latest graph
// so the ref-based stacks read a stable value across renders.
const graphRef = useRef(graph);
graphRef.current = graph;
const undoStack = useRef<LogicGraph[]>([]);
const redoStack = useRef<LogicGraph[]>([]);
const clipboard = useRef<{ nodes: LogicNode[]; wires: LogicWire[] } | null>(null);
const [, setHistTick] = useState(0); // re-render so toolbar enabled-state updates
const bump = () => setHistTick(t => t + 1);
const canUndo = undoStack.current.length > 0;
const canRedo = redoStack.current.length > 0;
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (!ds || ds === 'local' || ds === 'sys' || dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
// 'sys' exposes the engine's built-in time signals ({sys:time}, {sys:dt}).
const dsOptions = ['local', 'sys', ...dataSources];
function signalOptions(ds: string): string[] {
if (ds === 'local') return (statevars ?? []).map(v => v.name);
if (ds === 'sys') return ['time', 'dt'];
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(() => {
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
loadSignals(splitRef(selected.params.signal ?? '').ds);
}
if (selected?.kind === 'action.write') {
loadSignals(splitRef(selected.params.target ?? '').ds);
}
if (selected && selected.kind.startsWith('action.dialog.')) {
for (const f of parseDialogFields(selected)) {
if (f.type === 'input') loadSignals(splitRef(f.target ?? '').ds);
}
}
}, [selectedNode, selected?.kind]);
// ── History ────────────────────────────────────────────────────────────────
// Push the current graph onto the undo stack (clearing redo). Call right
// before a discrete edit; for continuous gestures (node drag) call once.
function pushUndo() {
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
redoStack.current = [];
bump();
}
function undo() {
if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1];
redoStack.current = [graphRef.current, ...redoStack.current];
undoStack.current = undoStack.current.slice(0, -1);
setSelectedNode(null);
setSelectedWire(null);
onChange(prev);
bump();
}
function redo() {
if (redoStack.current.length === 0) return;
const next = redoStack.current[0];
undoStack.current = [...undoStack.current, graphRef.current];
redoStack.current = redoStack.current.slice(1);
setSelectedNode(null);
setSelectedWire(null);
onChange(next);
bump();
}
// ── Graph mutators ─────────────────────────────────────────────────────────
// Apply a new graph, recording an undo entry unless `record` is false (used
// for the intermediate frames of a node drag, which record once on first move).
function setGraph(next: LogicGraph, record = true) {
if (record) pushUndo();
onChange(next);
}
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires }, record); }
function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next }); }
function addNode(entry: PaletteEntry, x?: number, y?: number) {
const node: LogicNode = {
id: genId(),
kind: entry.kind,
x: x ?? 40 + (nodes.length % 5) * 30,
y: y ?? 40 + (nodes.length % 5) * 30,
params: { ...entry.params },
};
setGraph({ nodes: [...nodes, node], wires });
setSelectedNode(node.id);
setSelectedWire(null);
}
function patchParams(id: string, patch: Record<string, string>) {
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
}
function moveNode(id: string, x: number, y: number, record = false) {
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record);
}
function deleteNode(id: string) {
setGraph({
nodes: nodes.filter(n => n.id !== id),
wires: wires.filter(w => w.from !== id && w.to !== id),
});
if (selectedNode === id) setSelectedNode(null);
}
function addWire(from: string, port: string, to: string) {
if (from === to) return;
if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return;
setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]);
}
function deleteWire(idx: number) {
setWires(wires.filter((_, i) => i !== idx));
if (selectedWire === idx) setSelectedWire(null);
}
// ── Clipboard ────────────────────────────────────────────────────────────
// Copy the selected node (params deep-cloned). Single-selection only, but the
// paste remap is written generically so it extends to multi-select.
function copySelection() {
const n = graphRef.current.nodes.find(x => x.id === selectedNode);
if (!n) return;
clipboard.current = { nodes: [{ ...n, params: { ...n.params } }], wires: [] };
}
function pasteClipboard() {
const clip = clipboard.current;
if (!clip || clip.nodes.length === 0) return;
const idMap = new Map<string, string>();
const cur = graphRef.current;
const newNodes = clip.nodes.map(n => {
const id = genId();
idMap.set(n.id, id);
return { ...n, id, x: n.x + 30, y: n.y + 30, params: { ...n.params } };
});
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); }
}
// ── Pointer geometry ───────────────────────────────────────────────────────
function toCanvas(e: MouseEvent): { x: number; y: number } {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
}
// ── Node dragging ──────────────────────────────────────────────────────────
function startNodeDrag(e: MouseEvent, node: LogicNode) {
e.stopPropagation();
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelectedNode(node.id);
setSelectedWire(null);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
const p = toCanvas(e);
// Record a single undo entry per drag (on the first move, so a plain
// click-to-select doesn't create a spurious history step).
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
// ── Wire drawing ───────────────────────────────────────────────────────────
function startWire(e: MouseEvent, node: LogicNode, port: string) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, port, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
// Tear down an in-progress wire drag. Called both on a plain release over
// empty canvas (window mouseup) and after a connection is made — the latter
// is essential because the target port's mouseup calls stopPropagation, so
// the window listener never fires and would otherwise leave a phantom wire
// following the cursor.
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() {
endWire();
}
function finishWire(target: LogicNode) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
endWire();
}
// ── Palette drag-and-drop ────────────────────────────────────────────────
const DRAG_MIME = 'application/x-uopi-logic-node';
function onCanvasDragOver(e: DragEvent) {
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
}
function onCanvasDrop(e: DragEvent) {
const kind = e.dataTransfer?.getData(DRAG_MIME);
if (!kind) return;
e.preventDefault();
const entry = PALETTE.find(p => p.kind === kind);
if (!entry) return;
const p = toCanvas(e);
// Drop point becomes the node's top-left, offset so the cursor lands inside.
addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
}
// ── Keyboard shortcuts (delete / undo / redo / copy / paste) ────────────────
useEffect(() => {
function onKey(e: KeyboardEvent) {
const t = e.target as Element;
// While typing in a field, leave native text editing shortcuts alone.
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
else if (selectedNode) deleteNode(selectedNode);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedNode, selectedWire, nodes, wires]);
const byId = new Map(nodes.map(n => [n.id, n]));
// Array names already referenced by accumulate/export/clear nodes, offered as
// autocomplete suggestions so the same array is reused across nodes.
const arrayNames = Array.from(new Set(
nodes.map(n => n.params.array).filter((a): a is string => !!a)
));
// Append a {ds:name} reference into a node's expression field.
function insertRef(id: string, field: string, ds: string, sig: string) {
const cur = (byId.get(id)?.params[field]) ?? '';
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
}
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
</div>
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
<div class="flow-palette-title">Logic</div>
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
<div class="flow-palette-title">Actions</div>
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
<div class="flow-palette-hint hint">
Triggers start a flow. Drag a node's right port to another node's left port to connect.
In expressions, reference signals as <code>{'{ds:name}'}</code> and local vars by name.
</div>
{onStateVarsChange && (
<LocalVars statevars={statevars ?? []} onChange={onStateVarsChange} />
)}
<datalist id="flow-array-names">
{arrayNames.map(a => <option key={a} value={a} />)}
</datalist>
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<svg class="flow-wires">
{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, w.fromPort ?? 'out');
const p2 = inAnchor(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} />
);
})}
{pendingWire && (() => {
const a = byId.get(pendingWire.from);
if (!a) return null;
const p1 = outAnchor(a, pendingWire.port);
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
})()}
</svg>
{nodes.map(node => (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<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>
{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); }} />
)}
{outputs(node.kind).map((port, i) => (
<Fragment key={port.id}>
{port.label && (
<span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
)}
<div class={`flow-port flow-port-out flow-port-${port.id}`}
style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
title={`Output: ${port.id}`}
onMouseDown={(e) => startWire(e, node, port.id)} />
</Fragment>
))}
</div>
))}
</div>
</div>
<div class="flow-inspector">
{!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{selected && (
<Fragment>
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{selected.kind === 'trigger.button' && (
<div class="wizard-field">
<label>Action name</label>
<input class="prop-input" value={selected.params.name ?? ''}
placeholder="e.g. open_valve"
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
<p class="hint">Fires when a button widget's Action is set to this name.</p>
</div>
)}
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
return (
<Fragment>
<div class="wizard-field">
<label>Signal</label>
<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">
<div class="wizard-field" style="flex:0 0 4.5rem;">
<label>Op</label>
<select class="prop-select" value={selected.params.op ?? '>'}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
{THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div class="wizard-field" style="flex:1;">
<label>Value</label>
<input class="prop-input" value={selected.params.value ?? ''}
onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
</div>
</div>
)}
{selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
</Fragment>
);
})()}
{(selected.kind === 'trigger.timer' || selected.kind === 'trigger.loop') && (
<div class="wizard-field">
<label>Interval (ms)</label>
<input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
{selected.kind === 'trigger.loop' && <p class="hint">Runs once on panel load, then on every interval.</p>}
</div>
)}
{selected.kind === 'gate.and' && (
<p class="hint">Wire two or more triggers into this gate. The flow continues only when all
inputs are satisfied (e.g. a Button click while a Threshold is currently true).</p>
)}
{selected.kind === 'flow.if' && (
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
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." />
)}
{selected.kind === 'flow.loop' && (
<Fragment>
<div class="wizard-field">
<label>Mode</label>
<select class="prop-select" value={selected.params.mode ?? 'count'}
onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
<option value="count">Repeat N times</option>
<option value="while">While condition</option>
</select>
</div>
{(selected.params.mode ?? 'count') === 'count' ? (
<div class="wizard-field">
<label>Repeat count</label>
<input class="prop-input" type="number" value={selected.params.count ?? '3'}
onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
</div>
) : (
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
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' && (
<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">
<label>Delay (ms)</label>
<input class="prop-input" type="number" value={selected.params.ms ?? '0'}
onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
</div>
)}
{selected.kind === 'action.accumulate' && (
<Fragment>
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
</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="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
{selected.kind === 'action.export' && (
<ExportEditor
columns={parseExportColumns(selected)}
align={selected.params.align ?? 'common'}
filename={selected.params.filename ?? ''}
onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
onAlign={(a) => patchParams(selected.id, { align: a })}
onFilename={(f) => patchParams(selected.id, { filename: f })} />
)}
{selected.kind === 'action.clear' && (
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
<p class="hint">Empties the named array (e.g. to start a fresh capture).</p>
</div>
)}
{selected.kind === 'action.log' && (
<Fragment>
<div class="wizard-field">
<label>Label (optional)</label>
<input class="prop-input" value={selected.params.label ?? ''}
placeholder="prefix for the console line"
onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
</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="Logs this value to the browser console — handy for debugging a flow." />
</Fragment>
)}
{selected.kind === 'action.widget' && (() => {
const op = selected.params.op ?? 'disable';
const plotOnly = op === 'clearplot' || op === 'pauseplot' || op === 'resumeplot';
const choices = plotOnly ? widgets.filter(w => w.type === 'plot') : widgets;
return (
<Fragment>
<div class="wizard-field">
<label>Action</label>
<select class="prop-input" value={op}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
<option value="show">Show</option>
<option value="hide">Hide</option>
<option value="clearplot">Clear plot</option>
<option value="pauseplot">Pause plot</option>
<option value="resumeplot">Resume plot</option>
</select>
</div>
<div class="wizard-field">
<label>Widget</label>
<select class="prop-input" value={selected.params.widget ?? ''}
onChange={(e) => patchParams(selected.id, { widget: (e.target as HTMLSelectElement).value })}>
<option value=""> select a widget </option>
{choices.map(w => (
<option value={w.id}>{widgetLabel(w)}</option>
))}
</select>
{plotOnly && <p class="hint">Only plot widgets are listed for this action.</p>}
</div>
</Fragment>
);
})()}
{(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && (
<p class="hint">{selected.kind === 'trigger.start'
? 'Fires once when the panel is opened — use it to initialise state or greet the operator.'
: 'Fires once when the panel is closed — use it to write a safe value or log a closing message. Only immediate (no-delay) actions are guaranteed to run.'}</p>
)}
{(selected.kind === 'action.dialog.info' || selected.kind === 'action.dialog.error') && (
<Fragment>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder={selected.kind === 'action.dialog.error' ? 'Error' : 'Info'}
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={4} value={selected.params.message ?? ''}
placeholder="Shown to the operator. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
<p class="hint">Waits for the operator to dismiss the dialog before continuing.
Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p>
</div>
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
</Fragment>
)}
{selected.kind === 'action.dialog.setpoint' && (
<Fragment>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="Set value"
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="Prompt shown above the fields. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
<DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
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>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
</div>
</div>
);
function paletteBtn(entry: PaletteEntry) {
return (
<button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
title={`${entry.kind} — drag onto the canvas or click to add`}
draggable
onDragStart={(e) => {
e.dataTransfer?.setData(DRAG_MIME, entry.kind);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
}}
onClick={() => addNode(entry)}>{entry.label}</button>
);
}
}
// Expression input with a signal-insert helper and live validation.
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const err = checkExpr(value);
return (
<div class="wizard-field">
<label>{label}</label>
<input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
placeholder="expression"
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<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>
);
}
// ── Export CSV: multi-column editor ──────────────────────────────────────────
interface ExportColumn { array: string; label: string; }
// The columns configured on an action.export node. Reads the JSON `columns`
// param; falls back to the legacy single `array` param (so old panels migrate
// transparently the first time the node is edited).
function parseExportColumns(n: LogicNode): ExportColumn[] {
const raw = (n.params.columns ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((c: any) => ({ array: String(c?.array ?? ''), label: String(c?.label ?? '') }));
}
} catch {}
}
const a = (n.params.array ?? '').trim();
return [{ array: a, label: '' }];
}
const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
{ value: 'common', label: 'Common rows', hint: 'Only timestamps present in every array (intersection).' },
{ value: 'any', label: 'All rows', hint: 'Union of all timestamps; missing cells are left blank.' },
{ value: 'interpolate', label: 'Interpolate', hint: 'Union of all timestamps; missing cells linearly interpolated.' },
];
function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename }: {
columns: ExportColumn[];
align: string;
filename: string;
onColumns: (cols: ExportColumn[]) => void;
onAlign: (a: string) => void;
onFilename: (f: string) => void;
}) {
function patch(i: number, patch: Partial<ExportColumn>) {
onColumns(columns.map((c, j) => (j === i ? { ...c, ...patch } : c)));
}
const alignHint = ALIGN_OPTS.find(o => o.value === align)?.hint ?? '';
return (
<Fragment>
<div class="wizard-field">
<label>Columns (arrays)</label>
{columns.map((c, i) => (
<div key={i} class="flow-row-edit">
<input class="prop-input" value={c.array} list="flow-array-names"
placeholder="array name"
onInput={(e) => patch(i, { array: (e.target as HTMLInputElement).value })} />
<input class="prop-input" value={c.label}
placeholder="column label (optional)"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove column"
disabled={columns.length <= 1}
onClick={() => onColumns(columns.filter((_, j) => j !== i))}></button>
</div>
))}
<button class="panel-btn flow-row-add" onClick={() => onColumns([...columns, { array: '', label: '' }])}>+ Add column</button>
</div>
{columns.length > 1 && (
<div class="wizard-field">
<label>Alignment</label>
<select class="prop-select" value={align} onChange={(e) => onAlign((e.target as HTMLSelectElement).value)}>
{ALIGN_OPTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p class="hint">{alignHint}</p>
</div>
)}
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={filename}
placeholder="defaults to the column labels"
onInput={(e) => onFilename((e.target as HTMLInputElement).value)} />
<p class="hint">Downloads a CSV with columns: timestamp_ms, iso, then one column per array.</p>
</div>
</Fragment>
);
}
// ── Dialogs: multi-field editor ──────────────────────────────────────────────
interface DialogFieldSpec { type: 'input' | 'display'; label: string; target?: string; default?: string; expr?: string; }
// The fields configured on an action.dialog.* node. Reads the JSON `fields`
// param; for a legacy set-point node it synthesises a single input from the old
// target/default params so existing panels migrate transparently.
function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
const raw = (n.params.fields ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
type: f?.type === 'display' ? 'display' : 'input',
label: String(f?.label ?? ''),
target: String(f?.target ?? ''),
default: String(f?.default ?? ''),
expr: String(f?.expr ?? ''),
}));
}
} catch {}
}
if (n.kind === 'action.dialog.setpoint') {
return [{ type: 'input', label: '', target: n.params.target ?? '', default: n.params.default ?? '', expr: '' }];
}
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
}
function add(type: 'input' | 'display') {
onChange([...fields, { type, label: '', target: '', default: '', expr: '' }]);
}
return (
<div class="wizard-field">
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
{allowInput ? (
<select class="prop-select" value={f.type}
onChange={(e) => patch(i, { type: (e.target as HTMLSelectElement).value as 'input' | 'display' })}>
<option value="input">Input</option>
<option value="display">Display</option>
</select>
) : <span class="flow-field-kind">Display</span>}
<input class="prop-input" value={f.label} placeholder="label"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove field"
onClick={() => onChange(fields.filter((_, j) => j !== i))}></button>
</div>
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<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)"
onInput={(e) => patch(i, { default: (e.target as HTMLInputElement).value })} />
</Fragment>
) : (
<input class={`prop-input${checkExpr(f.expr ?? '') ? ' prop-input-error' : ''}`}
value={f.expr ?? ''} placeholder="value to show (expression, e.g. {stub:level})"
onInput={(e) => patch(i, { expr: (e.target as HTMLInputElement).value })} />
)}
</div>
);
})}
<div class="flow-field-add">
{allowInput && <button class="panel-btn flow-row-add" onClick={() => add('input')}>+ Add input</button>}
<button class="panel-btn flow-row-add" onClick={() => add('display')}>+ Add value to show</button>
</div>
</div>
);
}
// A human-friendly label for a widget in the Widget-control picker: its type
// plus its first signal (or id) so panels with many widgets stay legible.
function widgetLabel(w: Widget): string {
const sig = w.signals[0] ? `${w.signals[0].ds}:${w.signals[0].name}` : '';
return `${w.type}${sig ? ' · ' + sig : ''} (${w.id})`;
}
// One-line summary shown in a node block's body.
function nodeSummary(n: LogicNode): string {
switch (n.kind) {
case 'trigger.button': return n.params.name || '(unnamed)';
case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`;
case 'trigger.change': return ${n.params.signal || '?'}`;
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`;
case 'trigger.start': return 'on panel open';
case 'trigger.stop': return 'on panel close';
case 'gate.and': return 'all inputs';
case 'flow.if': return n.params.cond || '(no condition)';
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
? `repeat ${n.params.count || '0'}×`
: `while ${n.params.cond || '?'}`;
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`;
case 'action.export': {
const cols = parseExportColumns(n).filter(c => c.array);
const names = cols.map(c => c.label || c.array).join(', ');
return `export ${names || '?'} → csv`;
}
case 'action.clear': return `clear ${n.params.array || '?'}`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.widget': return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.error': return `error: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.setpoint': {
const fs = parseDialogFields(n);
const ins = fs.filter(f => f.type === 'input').length;
const shows = fs.filter(f => f.type === 'display').length;
const parts: string[] = [];
if (ins) parts.push(`ask ${ins}`);
if (shows) parts.push(`show ${shows}`);
return parts.length ? parts.join(', ') : 'ask…';
}
default: return '';
}
}
// Inline editor for the panel's local state variables, shown in the logic
// palette so they can be created without leaving the (full-width) logic tab.
function LocalVars({ statevars, onChange }: {
statevars: StateVar[];
onChange: (vars: StateVar[]) => void;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [type, setType] = useState<'number' | 'bool' | 'string'>('number');
const [initial, setInitial] = useState('0');
function add() {
const n = name.trim();
if (!n) return;
onChange([...statevars.filter(v => v.name !== n), { name: n, type, initial }]);
setName('');
setInitial('0');
}
return (
<div class="flow-localvars">
<div class="flow-palette-title">Local vars</div>
{statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>}
{statevars.map(v => (
<div key={v.name} class="flow-localvar-row">
<span class="flow-localvar-name" title={`${v.type ?? 'number'} = ${v.initial}`}>{v.name}</span>
<button class="flow-node-del" title="Remove"
onClick={() => onChange(statevars.filter(x => x.name !== v.name))}></button>
</div>
))}
{!open && (
<button class="flow-palette-btn flow-localvar-add" onClick={() => setOpen(true)}>+ Add variable</button>
)}
{open && (
<div class="flow-localvar-form">
<input class="prop-input" value={name} placeholder="name"
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
<select class="prop-select" value={type}
onChange={(e) => setType((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string')}>
<option value="number">number</option>
<option value="bool">bool</option>
<option value="string">string</option>
</select>
<input class="prop-input" value={initial} placeholder="initial"
onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
<div class="flow-localvar-actions">
<button class="panel-btn" onClick={add}>Add</button>
<button class="panel-btn" onClick={() => { setOpen(false); setName(''); }}>Cancel</button>
</div>
</div>
)}
</div>
);
}