import { h, Fragment } from 'preact'; import { useState, useEffect, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; import LuaEditor from './LuaEditor'; import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types'; import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes'; import { VersionTree, DiffViewer } from './VersionHistory'; import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups'; import { useFlowZoom, type Box } from './lib/flowZoom'; import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug'; import { useAuth } from './lib/auth'; interface DataSource { name: string; } interface SignalInfo { name: string; type?: string; } interface Props { // Existing signal name (edit mode). Omitted/empty in create mode. name?: string; // When true the editor starts blank and POSTs a new signal on save. create?: boolean; // Interface id used to bind panel-scoped signals when creating. panelId?: string; onClose: () => void; onSaved: () => void; } // ── Node-type catalogue ────────────────────────────────────────────────────── // Mirrors the backend dsp node set (internal/dsp/nodes.go). `arity` describes how // many input ports an op exposes: // fixed n — exactly n inputs (gain=1, subtract/divide=2, …) // min n — at least n inputs; one spare port appears past the wired count so the // user can keep adding (add/multiply) // named — the user names each input (expr/lua); ports follow params.vars type InArity = { kind: 'fixed'; n: number } | { kind: 'min'; n: number } | { kind: 'named' }; interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; } interface OpDef { type: string; label: string; arity: InArity; params: NodeParam[]; } const OPS: OpDef[] = [ { type: 'gain', label: 'Gain', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] }, { type: 'offset', label: 'Offset', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, { type: 'add', label: 'Add (Σ inputs)', arity: { kind: 'min', n: 2 }, params: [] }, { type: 'subtract', label: 'Subtract (a−b)', arity: { kind: 'fixed', n: 2 }, params: [] }, { type: 'multiply', label: 'Multiply (Π)', arity: { kind: 'min', n: 2 }, params: [] }, { type: 'divide', label: 'Divide (a÷b)', arity: { kind: 'fixed', n: 2 }, params: [] }, { type: 'moving_average', label: 'Moving Average', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'rms', label: 'RMS', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'lowpass', label: 'Low-pass', arity: { kind: 'fixed', n: 1 }, params: [ { label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' }, { label: 'Order (1–8)', key: 'order', type: 'number', default: '1' }, ]}, { type: 'derivative', label: 'Derivative', arity: { kind: 'fixed', n: 1 }, params: [] }, { type: 'integrate', label: 'Integrate', arity: { kind: 'fixed', n: 1 }, params: [] }, { type: 'clamp', label: 'Clamp', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { type: 'threshold', label: 'Threshold', arity: { kind: 'fixed', n: 1 }, params: [ { label: 'Threshold', key: 'threshold', type: 'number', default: '0' }, { label: 'High output', key: 'high', type: 'number', default: '1' }, { label: 'Low output', key: 'low', type: 'number', default: '0' }, ]}, { type: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] }, { type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] }, // ── Array (waveform) ops ── { type: 'index', label: 'Index (a[i])', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Index (i)', key: 'i', type: 'number', default: '0' }] }, { type: 'slice', label: 'Slice', arity: { kind: 'fixed', n: 1 }, params: [ { label: 'Start', key: 'start', type: 'number', default: '0' }, { label: 'End (0 = to end)', key: 'end', type: 'number', default: '0' }, ]}, { type: 'sum', label: 'Sum (Σ array)', arity: { kind: 'fixed', n: 1 }, params: [] }, { type: 'mean', label: 'Mean (array)', arity: { kind: 'fixed', n: 1 }, params: [] }, { type: 'min', label: 'Min (array)', arity: { kind: 'fixed', n: 1 }, params: [] }, { type: 'max', label: 'Max (array)', arity: { kind: 'fixed', n: 1 }, params: [] }, { type: 'length', label: 'Length', arity: { kind: 'fixed', n: 1 }, params: [] }, { type: 'fft', label: 'FFT (magnitude)', arity: { kind: 'fixed', n: 1 }, params: [] }, ]; const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o])); function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; } function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; } function opArity(type?: string): InArity { return OP_BY_TYPE.get(type ?? '')?.arity ?? { kind: 'fixed', n: 1 }; } // Default input names a,b,c,… for named (expr/lua) nodes. function letters(n: number): string[] { return Array.from({ length: Math.max(1, n) }, (_, i) => String.fromCharCode(97 + i)); } // ── Graph model (UI only — compiled to SynthGraph on save) ────────────────── type NodeKind = 'source' | 'op' | 'output'; interface GNode { id: string; kind: NodeKind; x: number; y: number; ds?: string; // source signal?: string; // source op?: string; // op type params?: Record; // op (named ops carry params.vars: string[]) } // A wire terminates on a specific input port (index) of the target node. interface GWire { from: string; to: string; toPort: number; } interface Graph { nodes: GNode[]; wires: GWire[]; groups?: NodeGroup[]; } // ── Geometry (rem-based, like the logic editor) ───────────────────────────── const REM = (() => { if (typeof document === 'undefined') return 16; return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; })(); const NODE_W = 10 * REM; // Logical canvas size (matches .flow-canvas-inner in styles.css); zoom scales it. const CANVAS_W = 125 * REM; const CANVAS_H = 87.5 * REM; const PORT_TOP = 1.7 * REM; // y of the first input/output port row const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports const PORT_R = 0.375 * REM; // Ports are absolutely positioned inside the node's padding box, which is offset // from the node's border-box origin (node.x/node.y) by the node borders: a 3px // colored accent on top and 1px on the sides. Wire anchors must add the same // offsets or they land at the top edge of the port circle instead of its center. const BORDER = 1; const BORDER_TOP = 3; // ── Group geometry (node grouping / collapsing) ───────────────────────────── const GROUP_PAD = 0.65 * REM; // padding around member nodes const GROUP_HEADER = 1.65 * REM; // height of the group title bar const GROUP_BOX_W = NODE_W; // collapsed-box width const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM; // collapsed-box height function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); } function hasOutput(k: NodeKind): boolean { return k !== 'output'; } // Number of input ports a node currently shows. function inPortCount(n: GNode, wires: GWire[]): number { if (n.kind === 'source') return 0; if (n.kind === 'output') return 1; const ar = opArity(n.op); if (ar.kind === 'fixed') return ar.n; if (ar.kind === 'named') return Math.max(1, (n.params?.vars as string[] | undefined)?.length ?? 0); // min: expose one spare port beyond the highest wired index so the user can add more. const wired = wires.filter(w => w.to === n.id).length; return Math.max(ar.n, wired + 1); } function inAnchor(n: GNode, port: number) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP + port * PORT_GAP }; } function outAnchor(n: GNode) { return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP }; } function nodeMinHeight(n: GNode, wires: GWire[]): number { const nIn = Math.max(1, inPortCount(n, wires)); return BORDER_TOP + PORT_TOP + nIn * PORT_GAP; } function wirePathStr(x1: number, y1: number, x2: number, y2: number): string { const dx = Math.max(40, Math.abs(x2 - x1) / 2); return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; } // Label shown beside an input port (named ops show their var name). function portLabel(n: GNode, port: number): string { if (opArity(n.op).kind === 'named') { const vars = (n.params?.vars as string[] | undefined) ?? []; return vars[port] ?? ''; } return ''; } // Build an initial graph from an existing SignalDef. Prefer the stored DAG; fall // back to laying out the legacy linear Inputs+Pipeline form. function buildInitial(def: SignalDef): Graph { if (def.graph && def.graph.nodes.length > 0) { const nodes: GNode[] = def.graph.nodes.map((n, i) => ({ id: n.id, kind: n.kind, x: n.x ?? (2 + (i % 3) * 12) * REM, y: n.y ?? (2 + Math.floor(i / 3) * 5) * REM, ds: n.ds, signal: n.signal, op: n.op, params: n.params ? { ...n.params } : undefined, })); const wires: GWire[] = []; for (const n of def.graph.nodes) { (n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port })); } const groups = def.graph.groups; return { nodes, wires, ...(groups && groups.length ? { groups: groups.map(g => ({ ...g, members: [...g.members] })) } : {}) }; } // Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right. const inputs = def.inputs && def.inputs.length > 0 ? def.inputs : (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []); const pipeline = def.pipeline ?? []; const nodes: GNode[] = []; const wires: GWire[] = []; const srcIds = inputs.map((inp, i) => { const id = genId(); nodes.push({ id, kind: 'source', x: 2 * REM, y: (2 + i * 5) * REM, ds: inp.ds, signal: inp.signal }); return id; }); const opIds = pipeline.map((nd, i) => { const id = genId(); const params: Record = { ...nd.params }; // The head op received every source as a,b,c,…; later ops a single input. if (opArity(nd.type).kind === 'named') params.vars = letters(i === 0 ? srcIds.length : 1); nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params }); return id; }); const outId = genId(); nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM }); const headId = opIds[0] ?? outId; srcIds.forEach((s, i) => wires.push({ from: s, to: headId, toPort: i })); for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1], toPort: 0 }); if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId, toPort: 0 }); return { nodes, wires }; } // Compile the visual graph into the backend DAG form. Each op/output node's // inputs are its wires ordered by target port index. function compile(g: Graph): SynthGraph { const output = g.nodes.find(n => n.kind === 'output'); const nodes: SynthGraphNode[] = g.nodes.map(n => { const inputs = g.wires .filter(w => w.to === n.id) .slice() .sort((a, b) => a.toPort - b.toPort) .map(w => w.from); if (n.kind === 'source') return { id: n.id, kind: 'source', ds: n.ds, signal: n.signal, x: n.x, y: n.y }; if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y }; return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y }; }); const groups = pruneGroups(g.groups, new Set(g.nodes.map(n => n.id))); return { nodes, output: output?.id ?? '', ...(groups.length ? { groups } : {}) }; } // Detect a cycle in the graph (Kahn count over input edges). function hasCycle(g: Graph): boolean { const indeg = new Map(); const succ = new Map(); for (const n of g.nodes) indeg.set(n.id, 0); for (const w of g.wires) { if (!indeg.has(w.to) || !indeg.has(w.from)) continue; indeg.set(w.to, (indeg.get(w.to) ?? 0) + 1); succ.set(w.from, [...(succ.get(w.from) ?? []), w.to]); } const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id); let visited = 0; while (queue.length) { const id = queue.shift()!; visited++; for (const s of succ.get(id) ?? []) { indeg.set(s, (indeg.get(s) ?? 0) - 1); if ((indeg.get(s) ?? 0) === 0) queue.push(s); } } return visited !== g.nodes.length; } // Validate the graph; return a per-node error map plus the first message found. function validate(g: Graph): { errors: Map; first?: string } { const errors = new Map(); const set = (id: string, msg: string) => { if (!errors.has(id)) errors.set(id, msg); }; if (!g.nodes.some(n => n.kind === 'output')) { return { errors, first: 'missing output node' }; } for (const n of g.nodes) { const ports = new Set(g.wires.filter(w => w.to === n.id).map(w => w.toPort)); if (n.kind === 'source') { if (!n.ds || !n.signal) set(n.id, 'pick a data source and signal'); continue; } if (n.kind === 'output') { if (!ports.has(0)) set(n.id, 'connect a value to the output'); continue; } const ar = opArity(n.op); if (ar.kind === 'fixed' || ar.kind === 'named') { const cnt = ar.kind === 'fixed' ? ar.n : ((n.params?.vars as string[] | undefined)?.length ?? 0); if (cnt === 0) { set(n.id, 'add at least one named input'); continue; } for (let i = 0; i < cnt; i++) { if (!ports.has(i)) { set(n.id, 'all inputs must be connected'); break; } } } else { // min: needs ≥ ar.n contiguous inputs starting at port 0. const cnt = ports.size; if (cnt < ar.n) { set(n.id, `needs at least ${ar.n} inputs`); continue; } for (let i = 0; i < cnt; i++) { if (!ports.has(i)) { set(n.id, 'inputs have a gap — reconnect them'); break; } } } } let first: string | undefined; if (hasCycle(g)) first = 'the graph contains a cycle'; if (!first) { for (const n of g.nodes) { const m = errors.get(n.id); if (m) { first = `${n.kind === 'op' ? opLabel(n.op ?? '') : n.kind}: ${m}`; break; } } } return { errors, first }; } function nodeSummary(n: GNode): string { if (n.kind === 'source') return n.ds && n.signal ? `${n.ds}:${n.signal}` : '(pick a signal)'; if (n.kind === 'output') return 'synthetic result'; const p = n.params ?? {}; switch (n.op) { case 'gain': return `× ${p.gain ?? 1}`; case 'offset': return `+ ${p.offset ?? 0}`; case 'moving_average': return `avg ${p.window ?? 10}`; case 'rms': return `rms ${p.window ?? 10}`; case 'lowpass': return `≤ ${p.freq ?? 1} Hz`; case 'clamp': return `[${p.min ?? 0}, ${p.max ?? 100}]`; case 'threshold': return `> ${p.threshold ?? 0}`; case 'expr': return String(p.expr ?? ''); case 'lua': return 'lua'; default: return opLabel(n.op ?? ''); } } export default function SyntheticGraphEditor({ name, create, panelId, onClose, onSaved }: Props) { const [def, setDef] = useState(null); const [graph, setGraph] = useState({ nodes: [], wires: [] }); const [selected, setSelected] = useState(null); const [selectedWire, setSelectedWire] = useState(null); // Multi-selection (Shift+click) and the selected group, for node grouping. const [selSet, setSelSet] = useState>(new Set()); const [selectedGroup, setSelectedGroup] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); // Quick-add HUD (S = signal, N = node); null when closed. const [hud, setHud] = useState(null); const [hudFilter, setHudFilter] = useState(''); // Version history pane (existing signals only). const [showVersions, setShowVersions] = useState(false); const [viewingVersion, setViewingVersion] = useState(null); const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null); const [verReload, setVerReload] = useState(0); // Identity fields — editable only when creating a new signal. const me = useAuth(); const [newName, setNewName] = useState(''); const [visibility, setVisibility] = useState<'panel' | 'user' | 'group' | 'global'>(panelId ? 'panel' : 'user'); const [scopeGroups, setScopeGroups] = useState([]); const [unit, setUnit] = useState(''); const [desc, setDesc] = useState(''); const [dispLow, setDispLow] = useState('0'); const [dispHigh, setDispHigh] = useState('100'); const sigName = create ? newName.trim() : (name ?? ''); const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); const canvasRef = useRef(null); const zoomRef = useRef(1); const dragNode = useRef<{ ids: string[]; ox: number; oy: number; starts: Map; pushed: boolean } | null>(null); const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null); const pendingRef = useRef(pendingWire); pendingRef.current = pendingWire; const graphRef = useRef(graph); graphRef.current = graph; const undoStack = useRef([]); const redoStack = useRef([]); const clipboard = useRef<{ nodes: GNode[]; wires: GWire[] } | null>(null); const [debug, setDebug] = useState(false); const [, setTick] = useState(0); const bump = () => setTick(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 || 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 })); } catch {} } // Flatten every known ds:name into a single list for the fuzzy SignalPicker, // and a hook to lazily load all data sources' signals when a picker opens. function allSignalOptions(): SignalOption[] { const out: SignalOption[] = []; for (const ds of dataSources) for (const s of (dsSignals[ds] ?? [])) out.push({ ds, name: s.name }); return out; } function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); } // Resolve a source node's data type from the upstream signal's metadata. // 'float64[]' is the only array type today; anything else is scalar, and an // unresolved/unloaded source is 'unknown' (no error, runtime typing wins). function sourceSynthType(n: SynthGraphNode): SynthType { if (!n.ds || !n.signal) return 'unknown'; const sigs = dsSignals[n.ds]; if (!sigs) return 'unknown'; const info = sigs.find(s => s.name === n.signal); if (!info || !info.type) return 'unknown'; return info.type === 'float64[]' ? 'array' : 'scalar'; } // HUD filtering: case-insensitive substring over signal name/ds and op label/type. function filteredSignalOptions(): SignalOption[] { const q = hudFilter.trim().toLowerCase(); const all = allSignalOptions(); if (!q) return all; return all.filter(o => o.name.toLowerCase().includes(q) || o.ds.toLowerCase().includes(q)); } function filteredOps(): OpDef[] { const q = hudFilter.trim().toLowerCase(); if (!q) return OPS; return OPS.filter(o => o.label.toLowerCase().includes(q) || o.type.toLowerCase().includes(q)); } useEffect(() => { if (create) { const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} }; setDef(blank); setGraph(buildInitial(blank)); setLoading(false); return; } fetch(`/api/v1/synthetic/${encodeURIComponent(name ?? '')}`) .then(r => r.ok ? r.json() : Promise.reject(r.statusText)) .then((d: SignalDef) => { setDef(d); setUnit(d.meta?.unit ?? ''); setDesc(d.meta?.description ?? ''); setDispLow(String(d.meta?.displayLow ?? '0')); setDispHigh(String(d.meta?.displayHigh ?? '100')); const g = buildInitial(d); setGraph(g); g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); }); }) .catch(e => setError(String(e))) .finally(() => setLoading(false)); }, [name, create]); // ── History ──────────────────────────────────────────────────────────────── function pushUndo() { undoStack.current = [...undoStack.current.slice(-49), graphRef.current]; redoStack.current = []; } // Commit a new graph. When `next.groups` is left undefined the current groups // are carried over, so ordinary node/wire edits never lose grouping; group // mutators pass an explicit (possibly empty) list to change it. function commit(next: Graph, record = true) { if (record) pushUndo(); const groups = next.groups !== undefined ? next.groups : graphRef.current.groups; setGraph({ nodes: next.nodes, wires: next.wires, ...(groups && groups.length ? { groups } : {}) }); } 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); setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null); setGraph(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); setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null); setGraph(next); bump(); } // ── Graph mutators ─────────────────────────────────────────────────────── function addSource() { const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds: dataSources[0] || '', signal: '' }; if (node.ds) loadSignals(node.ds); commit({ nodes: [...graph.nodes, node], wires: graph.wires }); setSelected(node.id); setSelectedWire(null); } // Add a source node pre-filled with a chosen ds:signal (used by the quick-add HUD). function addSourceWith(ds: string, signal: string) { const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds, signal }; if (ds) loadSignals(ds); commit({ nodes: [...graph.nodes, node], wires: graph.wires }); setSelected(node.id); setSelectedWire(null); } // Open the quick-add HUD; for signals, lazily load every source's signal list. function openHud(kind: 'signal' | 'node') { if (kind === 'signal') openAllSignals(); setHudFilter(''); setHud(kind); } function closeHud() { setHud(null); setHudFilter(''); } function addOp(op: OpDef, x?: number, y?: number) { const params: Record = {}; for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default; if (op.arity.kind === 'named') params.vars = ['a', 'b']; const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM }; commit({ nodes: [...graph.nodes, node], wires: graph.wires }); setSelected(node.id); setSelectedWire(null); } function patchNode(id: string, patch: Partial) { commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, ...patch } : n)), wires: graph.wires }); } function patchParam(id: string, key: string, val: string) { commit({ nodes: graph.nodes.map(n => { if (n.id !== id) return n; const pd = opParamDefs(n.op ?? '').find(p => p.key === key); const typed: any = pd?.type === 'number' ? parseFloat(val) : val; return { ...n, params: { ...n.params, [key]: typed } }; }), wires: graph.wires, }); } // Named-input (expr/lua) variable list editing. function addNamedVar(id: string) { commit({ nodes: graph.nodes.map(n => { if (n.id !== id) return n; const vars = [...((n.params?.vars as string[] | undefined) ?? [])]; vars.push(letters(vars.length + 1)[vars.length]); return { ...n, params: { ...n.params, vars } }; }), wires: graph.wires, }); } function renameNamedVar(id: string, idx: number, value: string) { commit({ nodes: graph.nodes.map(n => { if (n.id !== id) return n; const vars = [...((n.params?.vars as string[] | undefined) ?? [])]; vars[idx] = value; return { ...n, params: { ...n.params, vars } }; }), wires: graph.wires, }); } function removeNamedVar(id: string, idx: number) { const nodes = graph.nodes.map(n => { if (n.id !== id) return n; const vars = [...((n.params?.vars as string[] | undefined) ?? [])]; vars.splice(idx, 1); return { ...n, params: { ...n.params, vars } }; }); // Drop the wire at the removed port and shift higher ports down by one. const wires = graph.wires .filter(w => !(w.to === id && w.toPort === idx)) .map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w)); commit({ nodes, wires }); } // Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}. function moveNodes(pos: Map, record: boolean) { const g = graphRef.current; commit({ nodes: g.nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), wires: g.wires }, record); } function deleteNode(id: string) { const n = graph.nodes.find(x => x.id === id); if (!n || n.kind === 'output') return; // the output node is permanent const nextNodes = graph.nodes.filter(x => x.id !== id); const nextGroups = pruneGroups(graph.groups, new Set(nextNodes.map(nn => nn.id))); commit({ nodes: nextNodes, wires: graph.wires.filter(w => w.from !== id && w.to !== id), groups: nextGroups }); if (selected === id) setSelected(null); if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); } } // ── Clipboard ──────────────────────────────────────────────────────────── // Copy the current selection (multi-select aware) plus any wires internal to // it, with params deep-cloned. The permanent output node is never copied. function copySelection() { const cur = graphRef.current; const ids = selSet.size > 0 ? selSet : (selected ? new Set([selected]) : new Set()); const picked = cur.nodes.filter(n => ids.has(n.id) && n.kind !== 'output'); if (picked.length === 0) return; const pickedIds = new Set(picked.map(n => n.id)); const innerWires = cur.wires.filter(w => pickedIds.has(w.from) && pickedIds.has(w.to)); clipboard.current = { nodes: picked.map(n => ({ ...n, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined })), wires: innerWires.map(w => ({ ...w })), }; } 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 + 1.5 * REM, y: n.y + 1.5 * REM, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined }; }); 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)! })); for (const n of newNodes) if (n.kind === 'source' && n.ds) loadSignals(n.ds); commit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] }); setSelSet(new Set(newNodes.map(n => n.id))); setSelected(newNodes.length === 1 ? newNodes[0].id : null); setSelectedWire(null); setSelectedGroup(null); } // ── Groups ────────────────────────────────────────────────────────────────── function setGroups(next: NodeGroup[], record = true) { const g = graphRef.current; commit({ nodes: g.nodes, wires: g.wires, groups: next }, record); } function groupSelection() { if (selSet.size < 1) return; const members = graph.nodes.filter(n => selSet.has(n.id)).map(n => n.id); if (members.length < 1) return; const grp: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false }; setGroups([...(graph.groups ?? []), grp]); setSelectedGroup(grp.id); setSelected(null); setSelectedWire(null); } function ungroup(gid: string) { setGroups((graph.groups ?? []).filter(g => g.id !== gid)); if (selectedGroup === gid) setSelectedGroup(null); } function toggleCollapse(gid: string) { setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g))); } function renameGroup(gid: string, label: string) { setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, label } : g)), false); } function addWire(from: string, to: string, toPort: number) { if (from === to) return; const fn = graph.nodes.find(n => n.id === from); const tn = graph.nodes.find(n => n.id === to); if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return; // Reject a definitely type-incompatible link (e.g. a scalar into an array // reduction). 'unknown'/'any' ports never block — runtime typing is final. const { types } = inferNodeTypes(compile(graph), sourceSynthType); const accept = tn.kind === 'op' ? portAccept(tn.op ?? '') : 'any'; if (!typesCompatible(types.get(from) ?? 'unknown', accept)) { setError(`Incompatible connection: ${accept} port can't take a ${types.get(from)} value`); return; } // One wire per input port: replace any existing wire on that port. const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort)); if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return; commit({ nodes: graph.nodes, wires: [...wires, { from, to, toPort }] }); } function deleteWire(idx: number) { commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) }); if (selectedWire === idx) setSelectedWire(null); } // The lowest input port of `target` not yet wired (for node-body drops). function firstFreePort(target: GNode): number { const used = new Set(graphRef.current.wires.filter(w => w.to === target.id).map(w => w.toPort)); const cnt = inPortCount(target, graphRef.current.wires); for (let i = 0; i < cnt; i++) if (!used.has(i)) return i; return cnt; // all full — append (min-arity nodes grow) } // ── Pointer / drag / wire ────────────────────────────────────────────────── function toCanvas(e: MouseEvent) { const el = canvasRef.current!; const rect = el.getBoundingClientRect(); const z = zoomRef.current; return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z }; } // Begin dragging `ids` (one node, a multi-selection, or a whole group). All // members move together, keeping their relative offsets. function beginDrag(e: MouseEvent, ids: string[]) { const p = toCanvas(e); const starts = new Map(); for (const id of ids) { const n = graphRef.current.nodes.find(x => x.id === id); if (n) starts.set(id, { x: n.x, y: n.y }); } dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false }; } function startNodeDrag(e: MouseEvent, node: GNode) { e.stopPropagation(); // Shift+click toggles the node in the multi-selection without dragging. if (e.shiftKey) { const s = new Set(selSet); if (s.has(node.id)) s.delete(node.id); else s.add(node.id); setSelSet(s); setSelected(node.id); setSelectedWire(null); setSelectedGroup(null); return; } setSelectedWire(null); setSelectedGroup(null); // Drag the whole multi-selection if this node is part of it, else just it. const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id]; if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id])); setSelected(node.id); beginDrag(e, ids); } function startWire(e: MouseEvent, node: GNode) { e.stopPropagation(); const p = toCanvas(e); setPendingWire({ from: node.id, x: p.x, y: p.y }); } function endWire() { pendingRef.current = null; setPendingWire(null); } function finishWire(target: GNode, port: number) { const cur = pendingRef.current; if (cur && target.kind !== 'source') addWire(cur.from, target.id, port); endWire(); } // Single mount-time pointer handler. Reads live drag/wire state from refs so it // never goes stale across re-renders — fixes the "can't drop a dragged node" bug. useEffect(() => { function onMove(e: MouseEvent) { const d = dragNode.current; if (d) { const p = toCanvas(e); const record = !d.pushed; d.pushed = true; const dx = p.x - d.ox, dy = p.y - d.oy; const pos = new Map(); for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) }); moveNodes(pos, record); return; } const cur = pendingRef.current; if (cur) { const p = toCanvas(e); setPendingWire({ ...cur, x: p.x, y: p.y }); } } function onUp() { dragNode.current = null; if (pendingRef.current) endWire(); } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, []); // ── Palette drag-and-drop ────────────────────────────────────────────────── const DRAG_MIME = 'application/x-uopi-synth-op'; 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 type = e.dataTransfer?.getData(DRAG_MIME); if (!type) return; e.preventDefault(); const op = OP_BY_TYPE.get(type); if (!op) return; const p = toCanvas(e); addOp(op, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP)); } // ── Keyboard ──────────────────────────────────────────────────────────────── useEffect(() => { function onKey(e: KeyboardEvent) { const t = e.target as Element; 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 (mod) return; if (e.key === 'Escape') { if (hud) closeHud(); return; } if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; } if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; } // 'g' groups the current multi-selection. if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; } if (e.key !== 'Delete' && e.key !== 'Backspace') return; if (selectedGroup !== null) ungroup(selectedGroup); else if (selectedWire !== null) deleteWire(selectedWire); else if (selected) deleteNode(selected); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [selected, selectedWire, selectedGroup, selSet, graph, hud]); const byId = new Map(graph.nodes.map(n => [n.id, n])); const groups = graph.groups ?? []; const sel = graph.nodes.find(n => n.id === selected) ?? null; const { errors: nodeErrors, first: validationError } = validate(graph); // ── Group geometry (for the current render) ───────────────────────────────── const rectOf = (id: string): Rect | null => { const n = byId.get(id); return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) } : null; }; const groupGeom = groups.map(g => { const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER); const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null; return { g, bounds, box }; }); const hiddenNodes = new Set(); const boxByGroup = new Map(); for (const gg of groupGeom) if (gg.g.collapsed && gg.box) { boxByGroup.set(gg.g.id, gg.box); for (const m of gg.g.members) hiddenNodes.add(m); } const collapsedBoxOf = (id: string): Rect | null => { const g = groupContaining(groups, id); return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null; }; const resolveOut = (n: GNode) => { const box = collapsedBoxOf(n.id); return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n); }; const resolveIn = (n: GNode, port: number) => { const box = collapsedBoxOf(n.id); return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n, port); }; // A wire is hidden when both ends collapse into the same group. const wireHidden = (from: string, to: string) => { const gf = groupContaining(groups, from), gt = groupContaining(groups, to); return !!gf && gf === gt && gf.collapsed; }; // ── Pan / zoom ─────────────────────────────────────────────────────────────── const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) })); const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } = useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects); // ── Live / debug mode ──────────────────────────────────────────────────────── // Polls the server with the current (unsaved) graph; each node lights up with // its computed value. Stateful DSP nodes are flagged "approx" (single-shot eval). const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => { const res = await fetch('/api/v1/synthetic/trace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph: compile(graphRef.current) }), }); if (!res.ok) return null; const j = await res.json(); const snap: DebugSnapshot = new Map(); for (const [id, n] of Object.entries(j.nodes ?? {})) { const node = n as { value: any; type?: 'scalar' | 'array'; approx?: boolean }; snap.set(id, { value: node.value, type: node.type, approx: node.approx, active: node.value != null }); } return snap; }); // Static type propagation: infer each node's output type (scalar/array) to // colour wires and surface type-incompatible wirings as node errors. Mirrors // the backend dsp.OpOutputType rules (see lib/synthTypes.ts). const { types: nodeTypes, errors: typeErrors } = inferNodeTypes(compile(graph), sourceSynthType); for (const [id, msg] of typeErrors) if (!nodeErrors.has(id)) nodeErrors.set(id, msg); const typeError = !validationError ? [...typeErrors.values()][0] : undefined; const firstError = validationError ?? typeError; async function handleSave() { if (!def) return; if (create && !sigName) { setError('Enter a name for the new signal.'); return; } if (firstError) { setError(firstError); return; } setSaving(true); setError(''); try { const meta = { ...def.meta, unit: unit || undefined, description: desc || undefined, displayLow: parseFloat(dispLow), displayHigh: parseFloat(dispHigh), }; const body: any = { ...def, name: sigName, graph: compile(graph), meta, inputs: undefined, pipeline: undefined, ds: undefined, signal: undefined, }; if (create) { body.visibility = visibility; if (visibility === 'panel' && panelId) body.panel = panelId; if (visibility === 'group') body.groups = scopeGroups; } const res = await fetch( create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`, { method: create ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, ); if (!res.ok) { const j = await res.json().catch(() => ({ error: res.statusText })); throw new Error(j.error ?? res.statusText); } onSaved(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } } // Apply a fetched SignalDef to the editor (meta fields + rebuilt node graph). function applyDef(d: SignalDef, asEdit: boolean) { setDef(d); setUnit(d.meta?.unit ?? ''); setDesc(d.meta?.description ?? ''); setDispLow(String(d.meta?.displayLow ?? '0')); setDispHigh(String(d.meta?.displayHigh ?? '100')); const g = buildInitial(d); if (asEdit) commit(g); else { undoStack.current = []; redoStack.current = []; setGraph(g); bump(); } g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); }); } // Load a past revision into the editor as an undoable edit; saving promotes it // to a new current version (non-destructive). async function viewVersion(version: number) { if (canUndo && !confirm('Discard unsaved changes to load this version?')) return; try { const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}/versions/${version}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const d: SignalDef = await res.json(); applyDef(d, false); setViewingVersion(version); } catch (err) { setError(`Load version failed: ${err instanceof Error ? err.message : err}`); } } async function reloadCurrent() { try { const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); applyDef(await res.json(), false); setViewingVersion(null); } catch (err) { setError(`Reload failed: ${err instanceof Error ? err.message : err}`); } } // Port colour class. Output ports take the node's own inferred output type; // input ports take what the op accepts (gray when it accepts either type). function portTypeClass(t: SynthType | 'any'): string { if (t === 'array') return ' flow-port-type-array'; if (t === 'scalar') return ' flow-port-type-scalar'; return ' flow-port-type-any'; } function inPortTypeClass(n: GNode): string { return portTypeClass(n.kind === 'op' ? portAccept(n.op ?? '') : 'any'); } function outPortTypeClass(n: GNode): string { return portTypeClass(nodeTypes.get(n.id) ?? 'unknown'); } function nodeClass(n: GNode): string { const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow'; const err = nodeErrors.has(n.id) ? ' flow-node-error' : ''; return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}${err}`; } return (
e.stopPropagation()}>
Synthetic Signal{create ? ' — new' : ` — ${name}`} {!create && ( )}
{loading ? (

Loading…

) : (
Inputs
Operations
{OPS.map(op => ( ))}
Wire signals into each operation's input ports, then connect the result to Output. Formula / Lua nodes name their own inputs.
{ setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }} onDragOver={onCanvasDragOver} onDrop={onCanvasDrop}>
{/* Group frames (behind nodes). Collapsed groups render a compact box. */} {groupGeom.map(({ g, bounds, box }) => { if (!bounds) return null; const gsel = selectedGroup === g.id; if (g.collapsed && box) { return (
{ e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
e.stopPropagation()} onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
{g.members.length} node{g.members.length === 1 ? '' : 's'}
); } return (
{ e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
e.stopPropagation()} onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
); })} {graph.wires.map((w, idx) => { const a = byId.get(w.from); const b = byId.get(w.to); if (!a || !b) return null; if (wireHidden(w.from, w.to)) return null; const p1 = resolveOut(a); const p2 = resolveIn(b, w.toPort); const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar'; return ( { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} /> ); })} {pendingWire && (() => { const a = byId.get(pendingWire.from); if (!a) return null; const p1 = outAnchor(a); return ; })()} {graph.nodes.filter(n => !hiddenNodes.has(n.id)).map(node => { const nIn = inPortCount(node, graph.wires); const dbg = debug ? debugSnap.get(node.id) : undefined; const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : ''; return (
1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${grouped}`} title={nodeErrors.get(node.id) || undefined} style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`} onMouseDown={(e) => startNodeDrag(e, node)} onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}> {dbg && ( {(dbg.approx ? '~' : '') + formatBadge(dbg)} )}
{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')} {node.kind !== 'output' && ( )}
{nodeSummary(node)}
{Array.from({ length: nIn }, (_, port) => { const label = portLabel(node, port); return (
e.stopPropagation()} onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} /> {label && ( {label} )} ); })} {hasOutput(node.kind) && (
startWire(e, node)} /> )}
); })}
{!sel && (
Signal
{create ? (
setNewName((e.target as HTMLInputElement).value)} />
{visibility === 'group' && (
{me.groups.length === 0 && You are not in any group.} {me.groups.map(g => ( ))}
)}
) : (

Editing {name}.

)}
setUnit((e.target as HTMLInputElement).value)} />
setDesc((e.target as HTMLInputElement).value)} />
setDispLow((e.target as HTMLInputElement).value)} />
setDispHigh((e.target as HTMLInputElement).value)} />
Select a node to edit it, or add one from the palette.
)} {sel?.kind === 'source' && (
Input signal
{ const i = ref.indexOf(':'); if (i < 0) patchNode(sel.id, { ds: ref, signal: '' }); else patchNode(sel.id, { ds: ref.slice(0, i), signal: ref.slice(i + 1) }); }} />
)} {sel?.kind === 'op' && (
{opLabel(sel.op ?? '')}
{opArity(sel.op).kind === 'named' && (
{((sel.params?.vars as string[] | undefined) ?? []).map((v, i) => (
renameNamedVar(sel.id, i, (e.target as HTMLInputElement).value)} />
))}

Use these names in the {sel.op === 'lua' ? 'script' : 'formula'} below.

)} {opParamDefs(sel.op ?? '').length === 0 && opArity(sel.op).kind !== 'named' && (

No parameters — this operation transforms its inputs directly.

)} {opParamDefs(sel.op ?? '').map(pd => (
{pd.type === 'lua' ? ( patchParam(sel.id, pd.key, v)} /> ) : ( patchParam(sel.id, pd.key, (e.target as HTMLInputElement).value)} /> )}
))}
)} {sel?.kind === 'output' && (
Output

The value wired into this node becomes the signal {name}.

)}
)} {hud && (
e.stopPropagation()}> setHudFilter((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); closeHud(); return; } if (e.key === 'Enter') { e.preventDefault(); if (hud === 'signal') { const opt = filteredSignalOptions()[0]; if (opt) { addSourceWith(opt.ds, opt.name); closeHud(); } } else { const op = filteredOps()[0]; if (op) { addOp(op); closeHud(); } } } }} />
{hud === 'signal' ? filteredSignalOptions().slice(0, 50).map(opt => ( )) : filteredOps().map(op => ( ))} {hud === 'signal' && filteredSignalOptions().length === 0 && (
No matching signals.
)}
)} {!create && showVersions && (
Version history
{ onSaved(); setVerReload(k => k + 1); }} onPromoted={async () => { await reloadCurrent(); onSaved(); setVerReload(k => k + 1); }} onCompare={(av, bv) => setDiff({ av, bv })} onError={setError} />
)} {diff && ( setDiff(null)} /> )}
); }