280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
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<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
|
||
const [nodeType, setNodeType] = useState('gain');
|
||
const [params, setParams] = useState<Record<string, string>>({});
|
||
// 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<string[]>([]);
|
||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||
|
||
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<InputRef>) {
|
||
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<string, any> = {};
|
||
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 (
|
||
<div class="wizard-backdrop" onClick={onClose}>
|
||
<div class="wizard" onClick={(e) => e.stopPropagation()}>
|
||
<div class="wizard-header">
|
||
<span>New Synthetic Signal</span>
|
||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||
</div>
|
||
|
||
<div class="wizard-body">
|
||
{error && <p class="wizard-error">{error}</p>}
|
||
|
||
<div class="wizard-section-title">Signal identity</div>
|
||
<div class="wizard-field">
|
||
<label>Name</label>
|
||
<input class="prop-input" value={name}
|
||
placeholder="e.g. smoothed_pressure"
|
||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||
</div>
|
||
|
||
<div class="wizard-field">
|
||
<label>Visibility</label>
|
||
<select class="prop-select" value={visibility}
|
||
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
||
<option value="panel" disabled={!currentIfaceId}>
|
||
This panel only{currentIfaceId ? '' : ' (save panel first)'}
|
||
</option>
|
||
<option value="user">All my panels</option>
|
||
<option value="global">All panels (global)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="wizard-section-title">Input signals</div>
|
||
{inputs.map((inp, idx) => (
|
||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||
<div class="wizard-field" style="flex:1;">
|
||
<label>Data source</label>
|
||
<SearchableSelect
|
||
value={inp.ds}
|
||
options={dataSources}
|
||
onSelect={(ds) => updateInput(idx, { ds })}
|
||
/>
|
||
</div>
|
||
<div class="wizard-field" style="flex:2;">
|
||
<label>Signal</label>
|
||
<SearchableSelect
|
||
value={inp.signal}
|
||
options={dsSignals[inp.ds] || []}
|
||
onSelect={(signal) => updateInput(idx, { signal })}
|
||
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
|
||
/>
|
||
</div>
|
||
<button
|
||
class="icon-btn"
|
||
style="margin-bottom: 0.25rem;"
|
||
title="Remove input"
|
||
onClick={() => removeInput(idx)}
|
||
disabled={inputs.length === 1}
|
||
>✕</button>
|
||
</div>
|
||
))}
|
||
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
|
||
|
||
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
|
||
<div class="wizard-field">
|
||
<label>Node type</label>
|
||
<select class="prop-select" value={nodeType}
|
||
onChange={(e) => { setNodeType((e.target as HTMLSelectElement).value); setParams({}); }}>
|
||
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
|
||
</select>
|
||
</div>
|
||
{nodeDef.params.map(p => (
|
||
<div key={p.key} class="wizard-field">
|
||
<label>{p.label}</label>
|
||
{p.type === 'lua' ? (
|
||
<LuaEditor
|
||
value={getParam(p.key, p.default)}
|
||
onChange={(v) => setParam(p.key, v)}
|
||
/>
|
||
) : (
|
||
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
|
||
value={getParam(p.key, p.default)}
|
||
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<div class="wizard-section-title">Metadata (optional)</div>
|
||
<div class="wizard-field wizard-field-row">
|
||
<div class="wizard-field">
|
||
<label>Unit</label>
|
||
<input class="prop-input" value={unit} placeholder="m/s"
|
||
onInput={(e) => setUnit((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>
|
||
<div class="wizard-field">
|
||
<label>Description</label>
|
||
<input class="prop-input" value={desc} placeholder="Optional description"
|
||
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wizard-footer">
|
||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleCreate} disabled={saving}>
|
||
{saving ? 'Creating…' : 'Create Signal'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|