import { h, Fragment } from 'preact'; import { useState, useEffect, useRef } from 'preact/hooks'; import SearchableSelect from './SearchableSelect'; import LuaEditor from './LuaEditor'; import type { InputRef, PipelineNode, SignalDef } from './lib/types'; interface DataSource { name: string; } interface SignalInfo { name: string; } interface Props { name: string; onClose: () => void; onSaved: () => void; } // ── Node-type catalogue ────────────────────────────────────────────────────── // Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes // (add/subtract/…) and expr take several inputs, which only the FIRST node of a // pipeline receives — see the compile() note below. interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; } interface OpDef { type: string; label: string; params: NodeParam[]; } const OPS: OpDef[] = [ { type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] }, { type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, { type: 'add', label: 'Add (Σ inputs)', params: [] }, { type: 'subtract', label: 'Subtract (a−b)', params: [] }, { type: 'multiply', label: 'Multiply (Π)', params: [] }, { type: 'divide', label: 'Divide (a÷b)', params: [] }, { type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'lowpass', label: 'Low-pass', params: [ { label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' }, { label: 'Order (1–8)', key: 'order', type: 'number', default: '1' }, ]}, { type: 'derivative', label: 'Derivative', params: [] }, { type: 'integrate', label: 'Integrate', params: [] }, { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { type: 'threshold', label: 'Threshold', params: [ { label: 'Threshold', key: 'threshold', type: 'number', default: '0' }, { label: 'High output', key: 'high', type: 'number', default: '1' }, { label: 'Low output', key: 'low', type: 'number', default: '0' }, ]}, { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] }, ]; 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 ?? []; } // ── Graph model (UI only — compiled to SignalDef 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 } interface GWire { from: string; to: string; } interface Graph { nodes: GNode[]; wires: GWire[]; } // ── 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; const PORT_TOP = 1.4 * REM; const PORT_R = 0.375 * REM; function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); } function hasInput(k: NodeKind): boolean { return k !== 'source'; } function hasOutput(k: NodeKind): boolean { return k !== 'output'; } function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; } function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; } function wirePathStr(x1: number, y1: number, x2: number, y2: number): string { const dx = Math.max(40, Math.abs(x2 - x1) / 2); return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; } // Build an initial graph by laying out an existing SignalDef: sources stacked on // the left, the linear pipeline as a row of op nodes, output on the right. function buildInitial(def: SignalDef): Graph { const inputs: InputRef[] = 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(); nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } }); return id; }); const outId = genId(); nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM }); const headId = opIds[0] ?? outId; srcIds.forEach(s => wires.push({ from: s, to: headId })); for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] }); if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId }); return { nodes, wires }; } // Compile the visual graph back into the backend's linear form. The pipeline is // a single chain ending at the output; the head node receives every source // signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output. function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } { const output = g.nodes.find(n => n.kind === 'output'); if (!output) return { error: 'missing output node' }; const byId = new Map(g.nodes.map(n => [n.id, n])); const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[]; const chain: GNode[] = []; const seen = new Set(); let cur: GNode = output; for (;;) { if (seen.has(cur.id)) return { error: 'the graph contains a cycle' }; seen.add(cur.id); const ups = upstream(cur.id); const opUps = ups.filter(n => n.kind === 'op'); const srcUps = ups.filter(n => n.kind === 'source'); if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' }; if (opUps.length === 1) { if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' }; chain.unshift(opUps[0]); cur = opUps[0]; continue; } // Reached the head of the chain: its source upstreams become the inputs. if (srcUps.length === 0) { return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' }; } const inputs = srcUps .slice() .sort((a, b) => a.y - b.y) .map(n => ({ ds: n.ds || '', signal: n.signal || '' })); if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' }; const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} })); return { inputs, pipeline }; } } 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, onClose, onSaved }: Props) { const [def, setDef] = useState(null); const [graph, setGraph] = useState({ nodes: [], wires: [] }); const [selected, setSelected] = useState(null); const [selectedWire, setSelectedWire] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); const canvasRef = useRef(null); const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null); const [pendingWire, setPendingWire] = useState<{ from: string; 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 [, 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.map(s => s.name) })); } catch {} } useEffect(() => { fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`) .then(r => r.ok ? r.json() : Promise.reject(r.statusText)) .then((d: SignalDef) => { setDef(d); 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]); // ── History ──────────────────────────────────────────────────────────────── function pushUndo() { undoStack.current = [...undoStack.current.slice(-49), graphRef.current]; redoStack.current = []; } function commit(next: Graph, record = true) { if (record) pushUndo(); setGraph(next); } 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); 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); 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); } 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; 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, }); } function moveNode(id: string, x: number, y: number, record: boolean) { commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.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 commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) }); if (selected === id) setSelected(null); } function addWire(from: string, to: string) { if (from === to) return; const fn = graph.nodes.find(n => n.id === from); const tn = graph.nodes.find(n => n.id === to); if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return; if (graph.wires.some(w => w.from === from && w.to === to)) return; commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] }); } function deleteWire(idx: number) { commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) }); if (selectedWire === idx) setSelectedWire(null); } // ── Pointer / drag / wire ────────────────────────────────────────────────── function toCanvas(e: MouseEvent) { const el = canvasRef.current!; const rect = el.getBoundingClientRect(); return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop }; } function startNodeDrag(e: MouseEvent, node: GNode) { e.stopPropagation(); const p = toCanvas(e); dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false }; setSelected(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); 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); } function startWire(e: MouseEvent, node: GNode) { e.stopPropagation(); const p = toCanvas(e); setPendingWire({ from: node.id, 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 }); } function endWire() { pendingRef.current = null; window.removeEventListener('mousemove', onWireMove); window.removeEventListener('mouseup', onWireUp); setPendingWire(null); } function onWireUp() { endWire(); } function finishWire(target: GNode) { const cur = pendingRef.current; if (cur && hasInput(target.kind)) addWire(cur.from, target.id); endWire(); } // ── 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 (e.key !== 'Delete' && e.key !== 'Backspace') return; if (selectedWire !== null) deleteWire(selectedWire); else if (selected) deleteNode(selected); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [selected, selectedWire, graph]); const byId = new Map(graph.nodes.map(n => [n.id, n])); const sel = graph.nodes.find(n => n.id === selected) ?? null; const compiled = compile(graph); async function handleSave() { if (!def) return; if (compiled.error) { setError(compiled.error); return; } setSaving(true); setError(''); try { const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined }; const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, { method: '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); } } function nodeClass(n: GNode): string { const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow'; return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`; } return (
e.stopPropagation()}>
Synthetic Signal — {name}
{loading ? (

Loading…

) : (
Inputs
Operations
{OPS.map(op => ( ))}
Wire input signals into the first operation, chain operations, and connect the last one to Output. The first node receives every input as a,b,c,d.
{ setSelected(null); setSelectedWire(null); }} onDragOver={onCanvasDragOver} onDrop={onCanvasDrop}>
{graph.wires.map((w, idx) => { const a = byId.get(w.from); const b = byId.get(w.to); if (!a || !b) return null; const p1 = outAnchor(a); const p2 = inAnchor(b); 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.map(node => (
startNodeDrag(e, node)} onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')} {node.kind !== 'output' && ( )}
{nodeSummary(node)}
{hasInput(node.kind) && (
e.stopPropagation()} onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} /> )} {hasOutput(node.kind) && (
startWire(e, node)} /> )}
))}
{!sel &&
Select a node to edit it, or add one from the palette.
} {sel?.kind === 'source' && (
Input signal
{ loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
patchNode(sel.id, { signal })} placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
)} {sel?.kind === 'op' && (
{opLabel(sel.op ?? '')}
{opParamDefs(sel.op ?? '').length === 0 && (

No parameters — this operation transforms its input 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}.

)}
)}
); }