import { h } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import LuaEditor from './LuaEditor'; import SearchableSelect from './SearchableSelect'; import type { InputRef, SignalDef } from './lib/types'; interface Props { onClose: () => void; onCreated: () => void; // Interface id the wizard was opened from — used to bind panel-scoped signals. currentIfaceId?: string; } interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; } const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [ { type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] }, { type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, { type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'lowpass', label: 'Low-pass Filter', params: [ { label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' }, { label: 'Order (1–8)', key: 'order', type: 'number', default: '1' }, ]}, { type: 'derivative', label: 'Derivative', params: [] }, { type: 'integrate', label: 'Integrate', params: [] }, { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] }, ]; interface DataSource { name: string; } interface SignalInfo { name: string; description?: string; } // ── Main Wizard ────────────────────────────────────────────────────────────── export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) { const [name, setName] = useState(''); const [inputs, setInputs] = useState([{ ds: 'stub', signal: 'sine_1hz' }]); const [nodeType, setNodeType] = useState('gain'); const [params, setParams] = useState>({}); // Visibility scope. Panel scope requires an interface to bind to, so when the // wizard is opened outside a saved panel it falls back to 'user'. const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user'); const [unit, setUnit] = useState(''); const [desc, setDesc] = useState(''); const [dispLow, setDispLow] = useState('0'); const [dispHigh, setDispHigh] = useState('100'); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); // Loaded data const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); 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 (dsSignals[ds]) return; try { const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`); if (!res.ok) return; const sigs: SignalInfo[] = await res.json(); setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) })); } catch {} } const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!; function getParam(key: string, def: string): string { return params[key] ?? def; } function setParam(key: string, val: string) { setParams(p => ({ ...p, [key]: val })); } function updateInput(idx: number, patch: Partial) { setInputs(prev => prev.map((inp, i) => { if (i !== idx) return inp; const next = { ...inp, ...patch }; if (patch.ds) { next.signal = ''; loadSignals(patch.ds); } return next; })); } function addInput() { setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]); } function removeInput(idx: number) { setInputs(prev => prev.filter((_, i) => i !== idx)); } async function handleCreate() { if (!name.trim()) { setError('Name is required'); return; } if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; } const nodeParams: Record = {}; for (const p of nodeDef.params) { const raw = getParam(p.key, p.default); nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw; } const def: SignalDef = { name: name.trim(), inputs, pipeline: [{ type: nodeType, params: nodeParams }], meta: { unit: unit || undefined, description: desc || undefined, displayLow: parseFloat(dispLow) || 0, displayHigh: parseFloat(dispHigh) || 100, }, visibility, ...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}), }; setSaving(true); setError(''); try { const res = await fetch('/api/v1/synthetic', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(def), }); if (!res.ok) { const j = await res.json().catch(() => ({ error: res.statusText })); throw new Error(j.error ?? res.statusText); } onCreated(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } } return (
e.stopPropagation()}>
New Synthetic Signal
{error &&

{error}

}
Signal identity
setName((e.target as HTMLInputElement).value)} />
Input signals
{inputs.map((inp, idx) => (
updateInput(idx, { ds })} />
updateInput(idx, { signal })} placeholder={inp.ds ? 'Search signals…' : 'Select data source first'} />
))}
Processing
{nodeDef.params.map(p => (
{p.type === 'lua' ? ( setParam(p.key, v)} /> ) : ( setParam(p.key, (e.target as HTMLInputElement).value)} /> )}
))}
Metadata (optional)
setUnit((e.target as HTMLInputElement).value)} />
setDispLow((e.target as HTMLInputElement).value)} />
setDispHigh((e.target as HTMLInputElement).value)} />
setDesc((e.target as HTMLInputElement).value)} />
); }