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:
+149
-65
@@ -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);
|
||||
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 });
|
||||
if (name) {
|
||||
const val = evalValue(node.params.expr ?? '', ctx.resolve);
|
||||
this.markActive(node.id, 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);
|
||||
}
|
||||
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 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(','));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user