1096 lines
52 KiB
TypeScript
1096 lines
52 KiB
TypeScript
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>
|
||
);
|
||
}
|