Remove legacy array logic nodes; migrate + dropdown-ify

Delete the action.accumulate / action.clear node kinds (superseded by the
typed action.array.* nodes). Old panels are migrated transparently on load
(xml.ts migrateLegacyArrayNode): accumulate→array.push, clear→array.clear,
and legacy single-`array` export folded into the JSON `columns` form.

Export CSV column pickers now use array-local dropdowns instead of free-text
+ datalist, matching the action.array.* node inspectors. Removes the now-dead
flow-array-names datalist and legacy single-`array` fallbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-24 14:21:11 +02:00
parent 05b06d64a4
commit 44c8f98e01
5 changed files with 61 additions and 104 deletions
+1 -1
View File
@@ -657,7 +657,7 @@ function SectionLogic() {
{[
{ type: 'Triggers', desc: 'Button press, threshold crossing, value change, timer/interval, panel loop, and On-open / On-close lifecycle.' },
{ type: 'Logic', desc: 'AND gate, If (then/else branches), and Loop (count or while).' },
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), and Accumulate / Export-CSV / Clear for in-memory data arrays.' },
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), Array push/set/remove/pop/clear on an array local variable, and Export-CSV.' },
{ type: 'Dialogs', desc: 'Info and Error pop-ups, and a Set-point prompt that asks the user for a number and writes it to a target.' },
].map(w => (
<div key={w.type} class="help-widget-row">
+18 -56
View File
@@ -73,9 +73,7 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
{ kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"data","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
{ kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.widget', label: 'Widget control', params: { widget: '', op: 'disable' } },
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
@@ -106,9 +104,7 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'flow.loop': 'Loop',
'action.write': 'Write',
'action.delay': 'Delay',
'action.accumulate': 'Accumulate',
'action.export': 'Export CSV',
'action.clear': 'Clear array',
'action.log': 'Log',
'action.widget': 'Widget control',
'action.dialog.info': 'Info dialog',
@@ -575,16 +571,6 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const byId = new Map(nodes.map(n => [n.id, n]));
// Array names already referenced by accumulate/export/clear nodes, offered as
// autocomplete suggestions so the same array is reused across nodes.
// Suggestions for the array-name autocomplete (export columns, accumulate,
// clear): declared array locals first, then any names already referenced by
// nodes (covers legacy/in-flight names not yet declared).
const arrayNames = Array.from(new Set([
...(statevars ?? []).filter(v => v.type === 'array').map(v => v.name),
...nodes.map(n => n.params.array).filter((a): a is string => !!a),
]));
// Append a {ds:name} reference into a node's expression field.
function insertRef(id: string, field: string, ds: string, sig: string) {
const cur = (byId.get(id)?.params[field]) ?? '';
@@ -684,9 +670,6 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
{onStateVarsChange && (
<LocalVars statevars={statevars ?? []} onChange={onStateVarsChange} />
)}
<datalist id="flow-array-names">
{arrayNames.map(a => <option key={a} value={a} />)}
</datalist>
</div>
<div class="flow-canvas" ref={canvasRef}
@@ -1033,25 +1016,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</Fragment>
)}
{selected.kind === 'action.accumulate' && (
<Fragment>
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
{selected.kind === 'action.export' && (
<ExportEditor
columns={parseExportColumns(selected)}
arrayLocals={(statevars ?? []).filter(v => v.type === 'array').map(v => v.name)}
align={selected.params.align ?? 'common'}
filename={selected.params.filename ?? ''}
onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
@@ -1059,16 +1027,6 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
onFilename={(f) => patchParams(selected.id, { filename: f })} />
)}
{selected.kind === 'action.clear' && (
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
<p class="hint">Empties the named array (e.g. to start a fresh capture).</p>
</div>
)}
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set' ||
selected.kind === 'action.array.remove' || selected.kind === 'action.array.pop' ||
selected.kind === 'action.array.clear') && (() => {
@@ -1268,9 +1226,9 @@ function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals
interface ExportColumn { array: string; label: string; }
// The columns configured on an action.export node. Reads the JSON `columns`
// param; falls back to the legacy single `array` param (so old panels migrate
// transparently the first time the node is edited).
// The columns configured on an action.export node, read from the JSON `columns`
// param. Legacy single-`array` nodes are migrated to this form on load (see
// xml.ts migrateLegacyArrayNode), so only the JSON form is read here.
function parseExportColumns(n: LogicNode): ExportColumn[] {
const raw = (n.params.columns ?? '').trim();
if (raw) {
@@ -1281,8 +1239,7 @@ function parseExportColumns(n: LogicNode): ExportColumn[] {
}
} catch {}
}
const a = (n.params.array ?? '').trim();
return [{ array: a, label: '' }];
return [{ array: '', label: '' }];
}
const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
@@ -1291,8 +1248,9 @@ const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
{ value: 'interpolate', label: 'Interpolate', hint: 'Union of all timestamps; missing cells linearly interpolated.' },
];
function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename }: {
function ExportEditor({ columns, arrayLocals, align, filename, onColumns, onAlign, onFilename }: {
columns: ExportColumn[];
arrayLocals: string[];
align: string;
filename: string;
onColumns: (cols: ExportColumn[]) => void;
@@ -1306,12 +1264,15 @@ function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename
return (
<Fragment>
<div class="wizard-field">
<label>Columns (arrays)</label>
<label>Columns (array local variables)</label>
{columns.map((c, i) => (
<div key={i} class="flow-row-edit">
<input class="prop-input" value={c.array} list="flow-array-names"
placeholder="array name"
onInput={(e) => patch(i, { array: (e.target as HTMLInputElement).value })} />
<select class="prop-select" value={c.array}
onChange={(e) => patch(i, { array: (e.target as HTMLSelectElement).value })}>
<option value=""> select array </option>
{c.array && !arrayLocals.includes(c.array) && <option value={c.array}>{c.array}</option>}
{arrayLocals.map(name => <option key={name} value={name}>{name}</option>)}
</select>
<input class="prop-input" value={c.label}
placeholder="column label (optional)"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
@@ -1321,6 +1282,9 @@ function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename
</div>
))}
<button class="panel-btn flow-row-add" onClick={() => onColumns([...columns, { array: '', label: '' }])}>+ Add column</button>
{arrayLocals.length === 0 && (
<p class="hint">No array locals declared yet add one in the Local vars panel.</p>
)}
</div>
{columns.length > 1 && (
<div class="wizard-field">
@@ -1455,13 +1419,11 @@ function nodeSummary(n: LogicNode): string {
: `while ${n.params.cond || '?'}`;
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`;
case 'action.export': {
const cols = parseExportColumns(n).filter(c => c.array);
const names = cols.map(c => c.label || c.array).join(', ');
return `export ${names || '?'} → csv`;
}
case 'action.clear': return `clear ${n.params.array || '?'}`;
case 'action.array.push': return `push ${n.params.expr || ''}${n.params.array || '?'}`;
case 'action.array.set': return `${n.params.array || '?'}[${n.params.index || '?'}] = ${n.params.expr || ''}`;
case 'action.array.remove': return `remove ${n.params.array || '?'}[${n.params.index || '?'}]`;
+7 -38
View File
@@ -12,8 +12,8 @@
// flow.loop — repeats the 'body' port (count / while, capped), then 'done'.
// action.write— evaluates `expr` and writes the result to `target`.
// action.delay— awaits `ms` before continuing.
// action.accumulate / export / clear — collect expression values into a named
// in-memory array and download it as CSV.
// action.array.* — push/set/remove/pop/clear on a named array local variable.
// action.export— download named array local variables as CSV (one col each).
// action.log — logs an expression value to the console.
//
// Expressions reference signals inline as {ds:name} and panel-local vars as
@@ -398,7 +398,6 @@ export class LogicEngine {
case 'flow.if': collectRefs(node.params.cond ?? '').forEach(want); break;
case 'flow.loop': if ((node.params.mode ?? 'count') === 'while') collectRefs(node.params.cond ?? '').forEach(want); break;
case 'action.write':
case 'action.accumulate':
case 'action.array.push':
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
case 'action.array.set':
@@ -686,35 +685,12 @@ export class LogicEngine {
return;
}
// ── Legacy array nodes (aliased to new implementations) ─────────────────
case 'action.accumulate': {
// Legacy alias → action.array.push (backs into array locals).
const name = (node.params.array ?? '').trim();
const val = evalValue(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (name) writeLocalState(name, [...curArray(name), val]);
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.export': {
if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.filename ?? '');
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.clear': {
// Legacy alias → action.array.clear (backed by array locals).
const name = (node.params.array ?? '').trim();
if (name) {
const sv = declaredVar(name);
writeLocalState(name, sv ? applySizing([], sv) : []);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.log': {
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
const label = (node.params.label ?? '').trim();
@@ -783,8 +759,9 @@ export class LogicEngine {
}
}
// The columns an action.export node should emit. Reads the JSON `columns`
// param (a list of { array, label }); falls back to the legacy single `array`.
// The columns an action.export node should emit, read from the JSON `columns`
// param (a list of { array, label }). Legacy single-`array` nodes are migrated
// to this form on load (see xml.ts migrateLegacyArrayNode).
private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
const raw = (node.params.columns ?? '').trim();
if (raw) {
@@ -797,8 +774,7 @@ export class LogicEngine {
}
} catch {}
}
const a = (node.params.array ?? '').trim();
return a ? [{ array: a, label: a }] : [];
return [];
}
// Dump one or more named array locals to a CSV the browser downloads.
@@ -928,20 +904,13 @@ export function ensureArrayDecls(graph: LogicGraph | undefined, vars: StateVar[]
}
};
for (const n of graph.nodes) {
if (
n.kind === 'action.accumulate' ||
n.kind === 'action.clear' ||
n.kind.startsWith('action.array.')
) {
if (n.kind.startsWith('action.array.')) {
need((n.params.array ?? '').trim());
}
if (n.kind === 'action.export') {
try {
(JSON.parse(n.params.columns || '[]') as any[]).forEach(c => need(String(c?.array ?? '').trim()));
} catch {}
// Also handle legacy single `array` param.
const a = (n.params.array ?? '').trim();
if (a) need(a);
}
}
return out;
+7 -8
View File
@@ -115,13 +115,14 @@ export interface StateVar {
// action.write — evaluate `expr` and write the result to `target`
// ("ds:name" or a bare local var) (params: target, expr).
// action.delay — pause `ms` before continuing downstream (params: ms).
// action.accumulate — evaluate `expr` and append the value (with a timestamp)
// to the named in-memory data array (params: array, expr).
// action.export — download one or more named arrays as a CSV file, one
// column per array (params: columns = JSON list of
// {array,label}, align 'common'|'any'|'interpolate',
// action.export — download one or more named array local variables as a
// CSV file, one column per array (params: columns = JSON
// list of {array,label}, align 'common'|'any'|'interpolate',
// filename). Legacy single-array nodes use `array`.
// action.clear — empty the named array (params: array).
// action.array.* — push/set/remove/pop/clear on a named array local variable
// (params: array, plus index/expr where relevant). The old
// action.accumulate / action.clear kinds are migrated to
// action.array.push / action.array.clear on load.
// action.log — evaluate `expr` and log it to the browser console for
// debugging a flow (params: expr, label).
// action.widget — drive a panel widget by id (params: widget, op). `op` is
@@ -156,9 +157,7 @@ export type LogicNodeKind =
| 'flow.loop'
| 'action.write'
| 'action.delay'
| 'action.accumulate'
| 'action.export'
| 'action.clear'
| 'action.log'
| 'action.widget'
| 'action.dialog.info'
+28 -1
View File
@@ -128,6 +128,31 @@ function parseLayout(el: Element): PlotLayout {
return { type: 'split', dir, ratio: isNaN(ratio) ? 0.5 : ratio, a, b };
}
// Rewrite a legacy array node kind to its current equivalent, mutating `params`
// in place. The old action.accumulate / action.clear kinds (and the legacy
// single-`array` form of action.export) predate the typed array local-variable
// nodes; old panels are migrated transparently on load so the editor only ever
// sees the current kinds.
function migrateLegacyArrayNode(
kind: string,
params: Record<string, string>,
setKind: (k: LogicGraph['nodes'][number]['kind']) => void,
): void {
if (kind === 'action.accumulate') {
setKind('action.array.push'); // same params: { array, expr }
} else if (kind === 'action.clear') {
setKind('action.array.clear'); // same params: { array }
} else if (kind === 'action.export') {
// Legacy single-array export had only an `array` param; fold it into the
// current JSON `columns` form so the editor never sees the legacy field.
const a = (params.array ?? '').trim();
if (a && !(params.columns ?? '').trim()) {
params.columns = JSON.stringify([{ array: a, label: '' }]);
}
delete params.array;
}
}
/** Parse a <logic> element into a LogicGraph (nodes + wires). */
function parseLogic(logicEl: Element): LogicGraph {
const nodes: LogicGraph['nodes'] = [];
@@ -160,9 +185,11 @@ function parseLogic(logicEl: Element): LogicGraph {
const value = child.getAttribute('value');
if (key !== null && value !== null) params[key] = value;
}
let kind = (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'];
migrateLegacyArrayNode(kind, params, (k) => { kind = k; });
nodes.push({
id,
kind: (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'],
kind,
x: parseFloat(el.getAttribute('x') ?? '0'),
y: parseFloat(el.getAttribute('y') ?? '0'),
params,