1069 lines
50 KiB
TypeScript
1069 lines
50 KiB
TypeScript
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, SynthType } from './lib/synthTypes';
|
||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||
|
||
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<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[]; }
|
||
|
||
// ── 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.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;
|
||
|
||
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 }));
|
||
}
|
||
return { nodes, wires };
|
||
}
|
||
|
||
// 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 };
|
||
});
|
||
return { nodes, output: output?.id ?? '' };
|
||
}
|
||
|
||
// 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);
|
||
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 [newName, setNewName] = useState('');
|
||
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(panelId ? 'panel' : 'user');
|
||
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 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<Graph[]>([]);
|
||
const redoStack = useRef<Graph[]>([]);
|
||
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 = [];
|
||
}
|
||
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);
|
||
}
|
||
// 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 });
|
||
}
|
||
function moveNode(id: string, x: number, y: number, record: boolean) {
|
||
const g = graphRef.current;
|
||
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : 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
|
||
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, 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;
|
||
// 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();
|
||
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);
|
||
}
|
||
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;
|
||
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), 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) 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; }
|
||
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, hud]);
|
||
|
||
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
||
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
||
const { errors: nodeErrors, first: validationError } = validate(graph);
|
||
|
||
// 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;
|
||
}
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
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>
|
||
</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); }}
|
||
onDragOver={onCanvasDragOver}
|
||
onDrop={onCanvasDrop}>
|
||
<div class="flow-canvas-inner">
|
||
<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;
|
||
const p1 = outAnchor(a); const p2 = inAnchor(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.map(node => {
|
||
const nIn = inPortCount(node, graph.wires);
|
||
return (
|
||
<div key={node.id}
|
||
class={nodeClass(node)}
|
||
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)); }}>
|
||
<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" 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" title="Output"
|
||
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
||
onMouseDown={(e) => startWire(e, node)} />
|
||
)}
|
||
</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="global">Global (all users)</option>
|
||
</select>
|
||
</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>
|
||
);
|
||
}
|