Files
uopi/web/src/SyntheticGraphEditor.tsx
T
2026-06-23 17:59:40 +02:00

1348 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import 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 (ab)', 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 (18)', 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<string, any>; // 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<string, any> = { ...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<string, number>();
const succ = new Map<string, string[]>();
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<string, string>; first?: string } {
const errors = new Map<string, string>();
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<SignalDef | null>(null);
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
// Multi-selection (Shift+click) and the selected group, for node grouping.
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(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 | 'signal' | 'node'>(null);
const [hudFilter, setHudFilter] = useState('');
// Version history pane (existing signals only).
const [showVersions, setShowVersions] = useState(false);
const [viewingVersion, setViewingVersion] = useState<number | null>(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<string[]>([]);
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<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const zoomRef = useRef(1);
const dragNode = useRef<{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: 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<Graph[]>([]);
const redoStack = useRef<Graph[]>([]);
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<string, any> = {};
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<GNode>) {
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<string, { x: number; y: number }>, 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<string>());
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<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 + 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<string, { x: number; y: number }>();
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<string, { x: number; y: number }>();
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<string>();
const boxByGroup = new Map<string, Rect>();
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 (
<div class="wizard-backdrop" onClick={onClose}>
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>Synthetic Signal{create ? ' — new' : ` — ${name}`}</span>
{!create && (
<button class={`toolbar-btn${showVersions ? ' toolbar-btn-primary' : ''}`}
style="margin-left:auto;" onClick={() => setShowVersions(v => !v)}>History</button>
)}
<button class="icon-btn" onClick={onClose}></button>
</div>
{loading ? (
<div class="wizard-body"><p class="hint">Loading</p></div>
) : (
<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>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title="Live debug — evaluate the graph and show each node's value"></button>
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Inputs</div>
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
<div class="flow-palette-title">Operations</div>
{OPS.map(op => (
<button key={op.type} class="flow-palette-btn flow-palette-flow"
title={`${op.type} — drag onto the canvas or click to add`}
draggable
onDragStart={(e) => { e.dataTransfer?.setData(DRAG_MIME, op.type); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'; }}
onClick={() => addOp(op)}>{op.label}</button>
))}
<div class="flow-palette-hint hint">
Wire signals into each operation's input ports, then connect the result
to <b>Output</b>. Formula / Lua nodes name their own inputs.
</div>
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{/* 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 (
<div key={g.id}
class={`flow-group-box${gsel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▸</button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${gsel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▾</button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}>✕</button>
</div>
</div>
);
})}
<svg class="flow-wires">
{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 (
<path key={idx}
class={`flow-wire${tClass}${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
);
})}
{pendingWire && (() => {
const a = byId.get(pendingWire.from);
if (!a) return null;
const p1 = outAnchor(a);
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
})()}
</svg>
{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 (
<div key={node.id}
class={`${nodeClass(node)}${selSet.has(node.id) && selSet.size > 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 && (
<span class={`flow-node-badge${dbg.approx ? ' approx' : ''}`} title={badgeTitle(dbg)}>
{(dbg.approx ? '~' : '') + formatBadge(dbg)}
</span>
)}
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<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>
{Array.from({ length: nIn }, (_, port) => {
const label = portLabel(node, port);
return (
<Fragment key={port}>
<div class={`flow-port flow-port-in${inPortTypeClass(node)}`} title={label || `Input ${port + 1}`}
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
{label && (
<span class="flow-port-label-in"
style={`top:${PORT_TOP + port * PORT_GAP - 0.6 * REM}px;`}>{label}</span>
)}
</Fragment>
);
})}
{hasOutput(node.kind) && (
<div class={`flow-port flow-port-out${outPortTypeClass(node)}`} title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
);
})}
</div>
</div>
</div>
<div class="flow-inspector">
{!sel && (
<Fragment>
<div class="wizard-section-title">Signal</div>
{create ? (
<Fragment>
<div class="wizard-field">
<label>Name</label>
<input class="prop-input" type="text" value={newName}
placeholder="my_signal"
onInput={(e) => setNewName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-input" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
{panelId && <option value="panel">This panel only</option>}
<option value="user">My signals</option>
<option value="group" disabled={me.groups.length === 0}>A group</option>
<option value="global">Global (all users)</option>
</select>
</div>
{visibility === 'group' && (
<div class="wizard-field">
<label>Groups</label>
<div class="scope-picker-groups">
{me.groups.length === 0 && <span class="hint">You are not in any group.</span>}
{me.groups.map(g => (
<label key={g} class="scope-picker-chk">
<input type="checkbox" checked={scopeGroups.includes(g)}
onChange={() => setScopeGroups(prev => prev.includes(g) ? prev.filter(x => x !== g) : [...prev, g])} />{g}
</label>
))}
</div>
</div>
)}
</Fragment>
) : (
<p class="hint">Editing <b>{name}</b>.</p>
)}
<div class="wizard-field">
<label>Unit</label>
<input class="prop-input" type="text" value={unit}
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Description</label>
<input class="prop-input" type="text" value={desc}
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display low</label>
<input class="prop-input" type="number" value={dispLow}
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display high</label>
<input class="prop-input" type="number" value={dispHigh}
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
</div>
<div class="flow-palette-hint hint">Select a node to edit it, or add one from the palette.</div>
</Fragment>
)}
{sel?.kind === 'source' && (
<Fragment>
<div class="wizard-section-title">Input signal</div>
<div class="wizard-field">
<label>Signal</label>
<SignalPicker
value={sel.signal ? `${sel.ds ?? ''}:${sel.signal}` : ''}
options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => {
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) });
}} />
</div>
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
)}
{sel?.kind === 'op' && (
<Fragment>
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
{opArity(sel.op).kind === 'named' && (
<div class="wizard-field">
<label>Inputs</label>
{((sel.params?.vars as string[] | undefined) ?? []).map((v, i) => (
<div key={i} class="synth-var-row">
<input class="prop-input" type="text" value={v}
onInput={(e) => renameNamedVar(sel.id, i, (e.target as HTMLInputElement).value)} />
<button class="icon-btn" title="Remove input"
onClick={() => removeNamedVar(sel.id, i)}>✕</button>
</div>
))}
<button class="panel-btn" style="margin-top:0.4rem;" onClick={() => addNamedVar(sel.id)}>+ Input</button>
<p class="hint" style="margin-top:0.4rem;">Use these names in the {sel.op === 'lua' ? 'script' : 'formula'} below.</p>
</div>
)}
{opParamDefs(sel.op ?? '').length === 0 && opArity(sel.op).kind !== 'named' && (
<p class="hint">No parameters — this operation transforms its inputs directly.</p>
)}
{opParamDefs(sel.op ?? '').map(pd => (
<div key={pd.key} class="wizard-field">
<label>{pd.label}</label>
{pd.type === 'lua' ? (
<LuaEditor value={String(sel.params?.[pd.key] ?? pd.default)}
onChange={(v) => patchParam(sel.id, pd.key, v)} />
) : (
<input class="prop-input" type={pd.type === 'number' ? 'number' : 'text'}
value={sel.params?.[pd.key] ?? pd.default}
onInput={(e) => patchParam(sel.id, pd.key, (e.target as HTMLInputElement).value)} />
)}
</div>
))}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
)}
{sel?.kind === 'output' && (
<Fragment>
<div class="wizard-section-title">Output</div>
<p class="hint">The value wired into this node becomes the signal <b>{name}</b>.</p>
</Fragment>
)}
</div>
</div>
)}
{hud && (
<div class="synth-hud-backdrop" onMouseDown={closeHud}>
<div class="synth-hud" onMouseDown={(e) => e.stopPropagation()}>
<input
class="synth-hud-input"
autoFocus
placeholder={hud === 'signal' ? 'Add signal filter by name' : 'Add node filter operations'}
value={hudFilter}
onInput={(e) => 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(); }
}
}
}} />
<div class="synth-hud-list">
{hud === 'signal'
? filteredSignalOptions().slice(0, 50).map(opt => (
<button key={`${opt.ds}:${opt.name}`} class="synth-hud-item"
onClick={() => { addSourceWith(opt.ds, opt.name); closeHud(); }}>
<span class="synth-hud-item-name">{opt.name}</span>
<span class="synth-hud-item-meta">{opt.ds}</span>
</button>
))
: filteredOps().map(op => (
<button key={op.type} class="synth-hud-item"
onClick={() => { addOp(op); closeHud(); }}>
<span class="synth-hud-item-name">{op.label}</span>
<span class="synth-hud-item-meta">{op.type}</span>
</button>
))}
{hud === 'signal' && filteredSignalOptions().length === 0 && (
<div class="hint synth-hud-empty">No matching signals.</div>
)}
</div>
</div>
</div>
)}
{!create && showVersions && (
<div class="ver-pane synth-ver-pane">
<div class="ver-pane-head">Version history
<button class="icon-btn" onClick={() => setShowVersions(false)}>✕</button>
</div>
<VersionTree
base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
viewing={viewingVersion}
dirty={canUndo}
reloadKey={verReload}
onView={viewVersion}
onForked={() => { onSaved(); setVerReload(k => k + 1); }}
onPromoted={async () => { await reloadCurrent(); onSaved(); setVerReload(k => k + 1); }}
onCompare={(av, bv) => setDiff({ av, bv })}
onError={setError} />
</div>
)}
{diff && (
<DiffViewer base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
av={diff.av} bv={diff.bv} title={`${sigName} — v${diff.av} → v${diff.bv}`}
onClose={() => setDiff(null)} />
)}
<div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && firstError && <span class="hint" style="flex:1;">{firstError}</span>}
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!firstError}>
{saving ? 'Saving' : 'Save Signal'}
</button>
</div>
</div>
</div>
);
}