diff --git a/web/src/HelpModal.tsx b/web/src/HelpModal.tsx
index 5967085..de4bcb3 100644
--- a/web/src/HelpModal.tsx
+++ b/web/src/HelpModal.tsx
@@ -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 => (
Empties the named array (e.g. to start a fresh capture).
-
- )}
-
{(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 (
-
+
{columns.map((c, i) => (
- patch(i, { array: (e.target as HTMLInputElement).value })} />
+
patch(i, { label: (e.target as HTMLInputElement).value })} />
@@ -1321,6 +1282,9 @@ function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename
))}
+ {arrayLocals.length === 0 && (
+
No array locals declared yet — add one in the Local vars panel.
+ )}
{columns.length > 1 && (
@@ -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 || '?'}]`;
diff --git a/web/src/lib/logic.ts b/web/src/lib/logic.ts
index 52ddd63..844af50 100644
--- a/web/src/lib/logic.ts
+++ b/web/src/lib/logic.ts
@@ -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;
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 8d33932..3197b60 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -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'
diff --git a/web/src/lib/xml.ts b/web/src/lib/xml.ts
index d655f99..f6e0bc4 100644
--- a/web/src/lib/xml.ts
+++ b/web/src/lib/xml.ts
@@ -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,
+ 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 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,