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; } 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 = { '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(null); const [selectedWire, setSelectedWire] = useState(null); const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); const canvasRef = useRef(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([]); const redoStack = useRef([]); 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) { 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(); 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 (
Triggers
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
Logic
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
Actions
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
Triggers start a flow. Drag a node's right port to another node's left port to connect. In expressions, reference signals as {'{ds:name}'} and local vars by name.
{onStateVarsChange && ( )} {arrayNames.map(a =>
{ setSelectedNode(null); setSelectedWire(null); }} onDragOver={onCanvasDragOver} onDrop={onCanvasDrop}>
{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 ( { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} /> ); })} {pendingWire && (() => { const a = byId.get(pendingWire.from); if (!a) return null; const p1 = outAnchor(a, pendingWire.port); return ; })()} {nodes.map(node => (
startNodeDrag(e, node)} onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
{KIND_LABEL[node.kind]}
{nodeSummary(node)}
{hasInput(node.kind) && (
e.stopPropagation()} onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} /> )} {outputs(node.kind).map((port, i) => ( {port.label && ( {port.label} )}
startWire(e, node, port.id)} /> ))}
))}
{!selected &&
Select a node to edit it, or add one from the palette.
} {selected && (
{KIND_LABEL[selected.kind]}
{selected.kind === 'trigger.button' && (
patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />

Fires when a button widget's Action is set to this name.

)} {(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => { return (
patchParams(selected.id, { signal: ref })} />
{selected.kind === 'trigger.threshold' && (
patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
)} {selected.kind === 'trigger.change' &&

Fires whenever the signal's value changes.

}
); })()} {(selected.kind === 'trigger.timer' || selected.kind === 'trigger.loop') && (
patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} /> {selected.kind === 'trigger.loop' &&

Runs once on panel load, then on every interval.

}
)} {selected.kind === 'gate.and' && (

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).

)} {selected.kind === 'flow.if' && ( 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' && (
{(selected.params.mode ?? 'count') === 'count' ? (
patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
) : ( 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" /> )}

Runs the body port repeatedly, then continues on done.

)} {selected.kind === 'action.write' && (
patchParams(selected.id, { target: ref })} />

Pick a signal, or type local:name to write a panel-local variable.

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." />
)} {selected.kind === 'action.delay' && (
patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
)} {selected.kind === 'action.accumulate' && (
patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
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." />
)} {selected.kind === 'action.export' && ( 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' && (
patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />

Empties the named array (e.g. to start a fresh capture).

)} {selected.kind === 'action.log' && (
patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
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." />
)} {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 (
{plotOnly &&

Only plot widgets are listed for this action.

}
); })()} {(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && (

{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.'}

)} {(selected.kind === 'action.dialog.info' || selected.kind === 'action.dialog.error') && (
patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />