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:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user