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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user