Add synthetic array (waveform) DSP support + UX improvements

Adds full array/waveform support through the synthetic DSP engine: a
dsp.Sample value model (scalar or []float64), array ops (index, slice,
sum, mean, min, max, length, fft) with an in-tree radix-2 FFT, and static
type propagation (OpOutputType) that the editor mirrors to colour wires by
data type and flag invalid wirings. Stateful filters and lua stay
scalar-only. Adds a waveform plot mode (x-vs-index trace).

Also: errored-node hover reasons, S/N add-signal/add-node HUD shortcuts in
the synthetic editor, and view-mode widgets that blend with the canvas
background (chrome kept in edit mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-20 17:06:55 +02:00
parent 446de7f1ee
commit f7f297c3df
18 changed files with 1470 additions and 57 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
return (
<div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type];
const inner = Comp
+1
View File
@@ -302,6 +302,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
<option value="waveform">Waveform (array)</option>
</select>
</Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
+124 -9
View File
@@ -3,9 +3,10 @@ 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';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
interface SignalInfo { name: string; type?: string; }
interface Props {
// Existing signal name (edit mode). Omitted/empty in create mode.
@@ -52,6 +53,18 @@ const OPS: OpDef[] = [
]},
{ 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; }
@@ -297,6 +310,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
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('');
// Identity fields — editable only when creating a new signal.
const [newName, setNewName] = useState('');
@@ -308,7 +324,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const sigName = create ? newName.trim() : (name ?? '');
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, 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);
@@ -338,7 +354,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
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) }));
setDsSignals(prev => ({ ...prev, [ds]: sigs }));
} catch {}
}
@@ -346,11 +362,36 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
// 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 name of (dsSignals[ds] ?? [])) out.push({ ds, name });
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: {} };
@@ -408,6 +449,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
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;
@@ -582,22 +637,34 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
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]);
}, [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 (validationError) { setError(validationError); return; }
if (firstError) { setError(firstError); return; }
setSaving(true);
setError('');
try {
@@ -692,9 +759,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
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${selectedWire === idx ? ' flow-wire-selected' : ''}`}
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); }} />
);
@@ -712,6 +780,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
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)); }}>
@@ -867,11 +936,57 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
</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>
)}
<div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && validationError && <span class="hint" style="flex:1;">{validationError}</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 || !!validationError}>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!firstError}>
{saving ? 'Saving' : 'Save Signal'}
</button>
</div>
+86
View File
@@ -0,0 +1,86 @@
// Static type propagation for the synthetic node-graph editor. This mirrors the
// Go runtime/compile rules in internal/dsp/types.go (OpOutputType) so the editor
// can colour wires by data type and flag type-incompatible wirings before save.
// Keep the two in sync — a parity test guards the pair.
import type { SynthGraph, SynthGraphNode } from './types';
export type SynthType = 'scalar' | 'array' | 'unknown';
// reductionOps collapse an array (or scalar) to a single scalar.
const REDUCTION_OPS = new Set(['index', 'length', 'sum', 'mean', 'min', 'max']);
// arrayProducerOps require an array input and yield an array.
const ARRAY_PRODUCER_OPS = new Set(['fft', 'slice']);
// scalarOnlyOps reject array inputs and yield a scalar (stateful filters + lua).
const SCALAR_ONLY_OPS = new Set(['moving_average', 'rms', 'lowpass', 'derivative', 'integrate', 'lua']);
// opOutputType reports an op's output type given its input types, plus an error
// message when the inputs are definitely incompatible with the op. 'unknown'
// inputs (a source whose real type isn't known yet) never trigger an error —
// runtime typing is authoritative.
export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType; error?: string } {
if (REDUCTION_OPS.has(op)) return { type: 'scalar' };
if (ARRAY_PRODUCER_OPS.has(op)) {
if (inputs.some(t => t === 'scalar')) return { type: 'unknown', error: `${op} requires an array input` };
return { type: 'array' };
}
if (SCALAR_ONLY_OPS.has(op)) {
if (inputs.some(t => t === 'array')) return { type: 'unknown', error: `${op} does not accept an array input` };
return { type: 'scalar' };
}
// Elementwise stateless ops (gain, offset, add, subtract, multiply, divide,
// clamp, threshold, expr): array if any input is an array, scalar if all
// inputs are definitely scalar, otherwise unknown.
if (inputs.some(t => t === 'array')) return { type: 'array' };
if (inputs.some(t => t === 'unknown')) return { type: 'unknown' };
return { type: 'scalar' };
}
// inferNodeTypes walks the DAG in dependency order and assigns each node an
// output type. Source node types come from `sourceType` (resolved from upstream
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
// per-node type-conflict messages for the editor's validation.
export function inferNodeTypes(
g: SynthGraph,
sourceType: (n: SynthGraphNode) => SynthType,
): { types: Map<string, SynthType>; errors: Map<string, string> } {
const types = new Map<string, SynthType>();
const errors = new Map<string, string>();
const byId = new Map(g.nodes.map(n => [n.id, n]));
// Kahn topological order over node inputs.
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 n of g.nodes) {
for (const from of n.inputs ?? []) {
if (!byId.has(from)) continue;
indeg.set(n.id, (indeg.get(n.id) ?? 0) + 1);
succ.set(from, [...(succ.get(from) ?? []), n.id]);
}
}
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
while (queue.length) {
const id = queue.shift()!;
const n = byId.get(id)!;
if (n.kind === 'source') {
types.set(id, sourceType(n));
} else if (n.kind === 'output') {
const from = (n.inputs ?? [])[0];
types.set(id, (from && types.get(from)) || 'unknown');
} else {
const inTypes = (n.inputs ?? []).map(f => types.get(f) ?? 'unknown');
const { type, error } = opOutputType(n.op ?? '', inTypes);
types.set(id, type);
if (error) errors.set(id, error);
}
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 { types, errors };
}
+79
View File
@@ -375,6 +375,23 @@ body {
/* width/height set inline from interface dimensions */
}
/* View mode: widgets shed their card chrome (border/background/shadow) so they
blend with the canvas background. Edit mode (EditCanvas) keeps the chrome so
widgets stay visible and selectable. Internal elements (gauge arc, bar track,
plot chart, inputs) keep their own styling. */
.canvas-view-bare .textview,
.canvas-view-bare .gauge,
.canvas-view-bare .barh,
.canvas-view-bare .barv,
.canvas-view-bare .led-widget,
.canvas-view-bare .multiled-widget,
.canvas-view-bare .setvalue,
.canvas-view-bare .plot-widget {
background: transparent;
border-color: transparent;
box-shadow: none;
}
/* ── TextLabel widget ──────────────────────────────────────────────────────── */
.textlabel {
@@ -1248,6 +1265,12 @@ body {
.flow-wire-selected { stroke: #ef4444; stroke-width: 2.5; }
.flow-wire-pending { stroke: #3b82f6; stroke-dasharray: 5 4; }
/* Synthetic-editor data-type wire colouring (scoped to .synth-graph so the
Logic / ControlLogic editors that share .flow-wire are unaffected). Scalar
keeps the default slate; array (waveform) signals are purple. */
.synth-graph .flow-wire.synth-wire-array { stroke: #a855f7; }
.synth-graph .flow-wire.synth-wire-array:hover { stroke: #c084fc; }
/* Node block */
.flow-node {
position: absolute;
@@ -2002,8 +2025,64 @@ body {
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
position: relative;
}
/* Quick-add HUD (S = signal, N = node) */
.synth-hud-backdrop {
position: absolute;
inset: 0;
background: rgba(10, 14, 22, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
z-index: 20;
}
.synth-hud {
width: 26rem;
max-width: 80%;
background: #1a1f2e;
border: 1px solid #3a4660;
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
display: flex;
flex-direction: column;
overflow: hidden;
}
.synth-hud-input {
border: none;
border-bottom: 1px solid #2d3748;
background: #0f1117;
color: #e2e8f0;
font-size: 0.9rem;
padding: 0.6rem 0.75rem;
outline: none;
}
.synth-hud-list {
max-height: 18rem;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.synth-hud-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: none;
border: none;
color: #e2e8f0;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.synth-hud-item:hover { background: #243049; }
.synth-hud-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.synth-hud-item-meta { color: #64748b; font-size: 0.72rem; flex-shrink: 0; }
.synth-hud-empty { padding: 0.6rem 0.75rem; }
.wizard-header {
display: flex;
align-items: center;
+30 -1
View File
@@ -84,6 +84,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
const waveforms: number[][] = signals.map(() => []);
const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? '';
@@ -269,6 +271,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
}),
};
}
case 'waveform': {
// Latest waveform (array) sample per signal, drawn as an x-vs-index
// trace. Each update replaces the trace rather than appending.
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
xAxis: { type: 'value', name: 'Index', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', name: 'Value', axisLine, axisLabel, splitLine },
series: signals.map((s, i) => ({
name: s.name,
type: 'line',
data: (waveforms[i] ?? []).map((v, k) => [k, v]),
lineStyle: { color: s.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
})),
};
}
case 'logic': {
// Logic-analyser style: each signal is a 0/1 step trace stacked on its
// own lane, with a relative-time x-axis (seconds before now).
@@ -423,7 +443,16 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
if (plotType === 'timeseries') {
if (plotType === 'waveform') {
// Array-valued sample: keep only the latest waveform and redraw.
if (Array.isArray(sv.value)) {
waveforms[i] = sv.value.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
} else {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
waveforms[i] = isNaN(v) ? [] : [v];
}
if (echart) echart.setOption(echartsOption(), { notMerge: true });
} else if (plotType === 'timeseries') {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return;
pushSample(buffers[i], ts, v);