feat(logic): array action nodes + accumulate/export unification + migration

Add five action.array.push|set|remove|pop|clear node kinds; replace the
legacy {t,v} in-memory arrays store with array locals as single source of
truth; alias action.accumulate→push and action.clear→array.clear for
backward compat; rewrite action.export as index-aligned CSV with custom
header labels; add ensureArrayDecls() auto-declare migration wired via
Canvas.tsx so undeclared arrays referenced by logic nodes are initialised.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-24 11:07:24 +02:00
parent 062bb44dba
commit 8f50bc2498
3 changed files with 159 additions and 68 deletions
+5 -3
View File
@@ -1,7 +1,7 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { initLocalState } from './lib/localstate';
import { logicEngine } from './lib/logic';
import { logicEngine, ensureArrayDecls } from './lib/logic';
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
import type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView';
@@ -119,9 +119,11 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
}
// Instantiate this panel's local state variables from their initial values.
// ensureArrayDecls auto-declares any array referenced by logic nodes that
// are not yet declared as state vars (backward-compat migration).
useEffect(() => {
initLocalState(iface?.statevars);
}, [iface?.id, iface?.statevars]);
initLocalState(ensureArrayDecls(iface?.logic, iface?.statevars ?? []));
}, [iface?.id, iface?.statevars, iface?.logic]);
// Activate panel logic for the live view; tear it down on unmount/panel switch.
useEffect(() => {
+147 -63
View File
@@ -28,8 +28,10 @@ import { wsClient } from './ws';
import { getSignalStore } from './stores';
import { writable } from './store';
import { setWidgetDisabled, setWidgetHidden, setPlotPaused, clearPlot, resetWidgetCmds } from './widgetCommands';
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
import type { SignalRef, SignalValue, LogicGraph, LogicNode, StateVar } from './types';
import { evalExpr, evalBool, collectRefs, evalValue, type Resolver } from './expr';
import { getLocalValueStore, writeLocalState, declaredVar } from './localstate';
import { applySizing, type ArrVal } from './arraypolicy';
// A single row in a dialog: either an `input` (prompts the operator for a value
// that is later written to its target) or a `display` (a read-only live value
@@ -177,27 +179,34 @@ interface DialogFieldSpec {
expr?: string; // display: expression evaluated and shown
}
// Linearly interpolate a value at time `t` from timestamp-sorted samples. Returns
// null when `t` falls outside the samples' own time range (no extrapolation).
function interpAt(samples: Array<{ t: number; v: number }>, t: number): number | null {
const n = samples.length;
if (n === 0 || t < samples[0].t || t > samples[n - 1].t) return null;
for (let i = 0; i < n - 1; i++) {
const a = samples[i], b = samples[i + 1];
if (t >= a.t && t <= b.t) {
if (b.t === a.t) return a.v;
return a.v + (b.v - a.v) * (t - a.t) / (b.t - a.t);
}
}
return samples[n - 1].v;
}
// Escape a CSV cell: quote and double inner quotes when it contains a comma,
// quote, or newline.
function csvEsc(s: string): string {
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
// Read the current array value of a local state variable. Returns an empty
// array if the variable doesn't exist or holds a non-array value.
function curArray(name: string): ArrVal[] {
const store = getLocalValueStore(name) as { get?: () => { value: any } };
const sv = typeof store.get === 'function' ? store.get() : null;
const v = sv?.value;
return Array.isArray(v) ? (v as ArrVal[]) : [];
}
// Nested index assignment: sets arr[path[0]][path[1]]...[path[n-1]] = v.
// Negative indices are resolved relative to the current sub-array length.
function setPath(arr: ArrVal[], path: number[], v: ArrVal): void {
let cur: ArrVal[] = arr;
for (let d = 0; d < path.length - 1; d++) {
let k = path[d]; if (k < 0) k = cur.length + k;
if (!Array.isArray(cur[k])) cur[k] = [];
cur = cur[k] as ArrVal[];
}
let last = path[path.length - 1]; if (last < 0) last = cur.length + last;
cur[last] = v;
}
interface WireOut { to: string; port: string }
// One flow run, started by an activating trigger. `dt` is the seconds elapsed
// since that trigger last fired (0 on its first fire); `resolve` is an
@@ -220,9 +229,6 @@ export class LogicEngine {
private prevVal = new Map<string, any>();
// signal key → trigger node ids that react to it (threshold / change).
private watchers = new Map<string, string[]>();
// Named in-memory data arrays, filled by action.accumulate and dumped by
// action.export. Each sample keeps the wall-clock time it was recorded.
private arrays = new Map<string, Array<{ t: number; v: number }>>();
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
private lastFire = new Map<string, number>();
private cleanups: Array<() => void> = [];
@@ -349,7 +355,6 @@ export class LogicEngine {
this.prevBool = new Map();
this.prevVal = new Map();
this.watchers = new Map();
this.arrays = new Map();
this.lastFire = new Map();
this.debugStates = new Map();
// Restore every widget to its default state so reopening a panel (whose
@@ -611,27 +616,94 @@ export class LogicEngine {
await this.follow(node.id, 'out', ctx);
return;
case 'action.accumulate': {
// ── Array action nodes ──────────────────────────────────────────────────
case 'action.array.push': {
const name = (node.params.array ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (name) {
const val = evalValue(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (name && !isNaN(val)) {
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
arr.push({ t: Date.now(), v: val });
writeLocalState(name, [...curArray(name), val]);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.set': {
const name = (node.params.array ?? '').trim();
if (name) {
const arr = curArray(name).slice();
const path = String(node.params.index ?? '').split(',')
.map(s => Math.trunc(evalExpr(s.trim(), ctx.resolve)));
const val = evalValue(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (path.length > 0 && path.every(i => !isNaN(i))) setPath(arr, path, val);
writeLocalState(name, arr);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.remove': {
const name = (node.params.array ?? '').trim();
if (name) {
const arr = curArray(name).slice();
const i = Math.trunc(evalExpr(node.params.index ?? '0', ctx.resolve));
const k = i < 0 ? arr.length + i : i;
if (k >= 0 && k < arr.length) arr.splice(k, 1);
this.markActive(node.id, k);
writeLocalState(name, arr);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.pop': {
const name = (node.params.array ?? '').trim();
if (name) {
const arr = curArray(name).slice();
arr.pop();
writeLocalState(name, arr);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.clear': {
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;
}
// ── 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.align ?? 'common', node.params.filename ?? '');
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) this.arrays.delete(name);
if (name) {
const sv = declaredVar(name);
writeLocalState(name, sv ? applySizing([], sv) : []);
}
await this.follow(node.id, 'out', ctx);
return;
}
@@ -722,43 +794,21 @@ export class LogicEngine {
return a ? [{ array: a, label: a }] : [];
}
// Dump one or more named arrays to a CSV the browser downloads. Each column is
// a captured array; rows are keyed by sample timestamp. `align` controls how
// arrays with differing timestamps are combined:
// common — only timestamps present in every array (intersection).
// any — the union of all timestamps; cells with no sample are blank.
// interpolate — the union of all timestamps; a missing cell is linearly
// interpolated between the array's surrounding samples (blank
// outside the array's own time range).
private exportArrays(cols: Array<{ array: string; label: string }>, align: string, filename: string): void {
// Dump one or more named array locals to a CSV the browser downloads.
// Rows are index-aligned: row r contains col[r] for each column; cells with
// no value at that index are blank. The header row uses the column's custom
// label (or its array name as fallback).
private exportArrays(cols: Array<{ array: string; label: string }>, filename: string): void {
if (cols.length === 0) return;
const series = cols.map((c, i) => ({
label: c.label || c.array || `col${i + 1}`,
samples: (this.arrays.get(c.array) ?? []).slice().sort((a, b) => a.t - b.t),
map: new Map<number, number>(),
}));
for (const s of series) for (const p of s.samples) s.map.set(p.t, p.v);
const tsSet = new Set<number>();
for (const s of series) for (const p of s.samples) tsSet.add(p.t);
let times = Array.from(tsSet).sort((a, b) => a - b);
if (align === 'common') times = times.filter(t => series.every(s => s.map.has(t)));
const cell = (s: typeof series[number], t: number): string => {
const exact = s.map.get(t);
if (exact !== undefined) return String(exact);
if (align === 'interpolate') {
const v = interpAt(s.samples, t);
return v === null ? '' : String(v);
const data = cols.map(c => curArray(c.array));
const rows = Math.max(0, ...data.map(d => d.length));
const header = cols.map((c, i) => csvEsc(c.label || c.array || `col${i + 1}`)).join(',');
const lines = [header];
for (let r = 0; r < rows; r++) {
lines.push(data.map(d => (r < d.length ? csvEsc(String(d[r])) : '')).join(','));
}
return ''; // 'any' (and 'common' never reaches here)
};
const header = ['timestamp_ms', 'iso', ...series.map(s => s.label)];
const rows = times.map(t => [String(t), new Date(t).toISOString(), ...series.map(s => cell(s, t))]);
const csv = [header, ...rows].map(r => r.map(csvEsc).join(',')).join('\n') + '\n';
const base = filename || series.map(s => s.label).join('_') || 'export';
const csv = lines.join('\n') + '\n';
const base = filename || cols.map(c => c.label || c.array).join('_') || 'export';
const safe = base.replace(/[^a-z0-9_.-]/gi, '_');
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
const blob = new Blob([csv], { type: 'text/csv' });
@@ -855,3 +905,37 @@ export class LogicEngine {
// Singleton instance, mirrored on wsClient's module-level pattern.
export const logicEngine = new LogicEngine();
// ensureArrayDecls scans a LogicGraph for array references in accumulate /
// clear / array.* / export nodes and auto-declares any that are not already
// present in `vars` as dynamic numeric array locals. Call this before
// initLocalState so that auto-declared arrays are initialised on panel load.
export function ensureArrayDecls(graph: LogicGraph | undefined, vars: StateVar[]): StateVar[] {
if (!graph) return vars;
const have = new Set(vars.map(v => v.name));
const out = vars.slice();
const need = (name: string) => {
if (name && !have.has(name)) {
have.add(name);
out.push({ name, type: 'array', elem: 'number', sizing: 'dynamic', initial: '' });
}
};
for (const n of graph.nodes) {
if (
n.kind === 'action.accumulate' ||
n.kind === 'action.clear' ||
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;
}
+5
View File
@@ -164,6 +164,11 @@ export type LogicNodeKind =
| 'action.dialog.info'
| 'action.dialog.error'
| 'action.dialog.setpoint'
| 'action.array.push'
| 'action.array.set'
| 'action.array.remove'
| 'action.array.pop'
| 'action.array.clear'
| 'action.config.apply'
| 'action.config.read'
| 'action.config.write'