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:
+5
-3
@@ -1,7 +1,7 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
import { initLocalState } from './lib/localstate';
|
import { initLocalState } from './lib/localstate';
|
||||||
import { logicEngine } from './lib/logic';
|
import { logicEngine, ensureArrayDecls } from './lib/logic';
|
||||||
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
|
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
|
||||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||||
import TextView from './widgets/TextView';
|
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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
initLocalState(iface?.statevars);
|
initLocalState(ensureArrayDecls(iface?.logic, iface?.statevars ?? []));
|
||||||
}, [iface?.id, iface?.statevars]);
|
}, [iface?.id, iface?.statevars, iface?.logic]);
|
||||||
|
|
||||||
// Activate panel logic for the live view; tear it down on unmount/panel switch.
|
// Activate panel logic for the live view; tear it down on unmount/panel switch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+149
-65
@@ -28,8 +28,10 @@ import { wsClient } from './ws';
|
|||||||
import { getSignalStore } from './stores';
|
import { getSignalStore } from './stores';
|
||||||
import { writable } from './store';
|
import { writable } from './store';
|
||||||
import { setWidgetDisabled, setWidgetHidden, setPlotPaused, clearPlot, resetWidgetCmds } from './widgetCommands';
|
import { setWidgetDisabled, setWidgetHidden, setPlotPaused, clearPlot, resetWidgetCmds } from './widgetCommands';
|
||||||
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
|
import type { SignalRef, SignalValue, LogicGraph, LogicNode, StateVar } from './types';
|
||||||
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
|
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
|
// 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
|
// 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
|
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,
|
// Escape a CSV cell: quote and double inner quotes when it contains a comma,
|
||||||
// quote, or newline.
|
// quote, or newline.
|
||||||
function csvEsc(s: string): string {
|
function csvEsc(s: string): string {
|
||||||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
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 }
|
interface WireOut { to: string; port: string }
|
||||||
// One flow run, started by an activating trigger. `dt` is the seconds elapsed
|
// 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
|
// 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>();
|
private prevVal = new Map<string, any>();
|
||||||
// signal key → trigger node ids that react to it (threshold / change).
|
// signal key → trigger node ids that react to it (threshold / change).
|
||||||
private watchers = new Map<string, string[]>();
|
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.
|
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
|
||||||
private lastFire = new Map<string, number>();
|
private lastFire = new Map<string, number>();
|
||||||
private cleanups: Array<() => void> = [];
|
private cleanups: Array<() => void> = [];
|
||||||
@@ -349,7 +355,6 @@ export class LogicEngine {
|
|||||||
this.prevBool = new Map();
|
this.prevBool = new Map();
|
||||||
this.prevVal = new Map();
|
this.prevVal = new Map();
|
||||||
this.watchers = new Map();
|
this.watchers = new Map();
|
||||||
this.arrays = new Map();
|
|
||||||
this.lastFire = new Map();
|
this.lastFire = new Map();
|
||||||
this.debugStates = new Map();
|
this.debugStates = new Map();
|
||||||
// Restore every widget to its default state so reopening a panel (whose
|
// 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);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'action.accumulate': {
|
// ── Array action nodes ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
case 'action.array.push': {
|
||||||
const name = (node.params.array ?? '').trim();
|
const name = (node.params.array ?? '').trim();
|
||||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
if (name) {
|
||||||
this.markActive(node.id, val);
|
const val = evalValue(node.params.expr ?? '', ctx.resolve);
|
||||||
if (name && !isNaN(val)) {
|
this.markActive(node.id, val);
|
||||||
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
|
writeLocalState(name, [...curArray(name), val]);
|
||||||
arr.push({ t: Date.now(), v: val });
|
|
||||||
}
|
}
|
||||||
await this.follow(node.id, 'out', ctx);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
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': {
|
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);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'action.clear': {
|
case 'action.clear': {
|
||||||
|
// Legacy alias → action.array.clear (backed by array locals).
|
||||||
const name = (node.params.array ?? '').trim();
|
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);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -722,43 +794,21 @@ export class LogicEngine {
|
|||||||
return a ? [{ array: a, label: a }] : [];
|
return a ? [{ array: a, label: a }] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dump one or more named arrays to a CSV the browser downloads. Each column is
|
// Dump one or more named array locals to a CSV the browser downloads.
|
||||||
// a captured array; rows are keyed by sample timestamp. `align` controls how
|
// Rows are index-aligned: row r contains col[r] for each column; cells with
|
||||||
// arrays with differing timestamps are combined:
|
// no value at that index are blank. The header row uses the column's custom
|
||||||
// common — only timestamps present in every array (intersection).
|
// label (or its array name as fallback).
|
||||||
// any — the union of all timestamps; cells with no sample are blank.
|
private exportArrays(cols: Array<{ array: string; label: string }>, filename: string): void {
|
||||||
// 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 {
|
|
||||||
if (cols.length === 0) return;
|
if (cols.length === 0) return;
|
||||||
const series = cols.map((c, i) => ({
|
const data = cols.map(c => curArray(c.array));
|
||||||
label: c.label || c.array || `col${i + 1}`,
|
const rows = Math.max(0, ...data.map(d => d.length));
|
||||||
samples: (this.arrays.get(c.array) ?? []).slice().sort((a, b) => a.t - b.t),
|
const header = cols.map((c, i) => csvEsc(c.label || c.array || `col${i + 1}`)).join(',');
|
||||||
map: new Map<number, number>(),
|
const lines = [header];
|
||||||
}));
|
for (let r = 0; r < rows; r++) {
|
||||||
for (const s of series) for (const p of s.samples) s.map.set(p.t, p.v);
|
lines.push(data.map(d => (r < d.length ? csvEsc(String(d[r])) : '')).join(','));
|
||||||
|
}
|
||||||
const tsSet = new Set<number>();
|
const csv = lines.join('\n') + '\n';
|
||||||
for (const s of series) for (const p of s.samples) tsSet.add(p.t);
|
const base = filename || cols.map(c => c.label || c.array).join('_') || 'export';
|
||||||
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 safe = base.replace(/[^a-z0-9_.-]/gi, '_');
|
const safe = base.replace(/[^a-z0-9_.-]/gi, '_');
|
||||||
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
|
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
|
||||||
const blob = new Blob([csv], { type: 'text/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.
|
// Singleton instance, mirrored on wsClient's module-level pattern.
|
||||||
export const logicEngine = new LogicEngine();
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ export type LogicNodeKind =
|
|||||||
| 'action.dialog.info'
|
| 'action.dialog.info'
|
||||||
| 'action.dialog.error'
|
| 'action.dialog.error'
|
||||||
| 'action.dialog.setpoint'
|
| 'action.dialog.setpoint'
|
||||||
|
| 'action.array.push'
|
||||||
|
| 'action.array.set'
|
||||||
|
| 'action.array.remove'
|
||||||
|
| 'action.array.pop'
|
||||||
|
| 'action.array.clear'
|
||||||
| 'action.config.apply'
|
| 'action.config.apply'
|
||||||
| 'action.config.read'
|
| 'action.config.read'
|
||||||
| 'action.config.write'
|
| 'action.config.write'
|
||||||
|
|||||||
Reference in New Issue
Block a user