From 8f50bc2498fe8c9543610a1b34637076d6f39ed3 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 24 Jun 2026 11:07:24 +0200 Subject: [PATCH] feat(logic): array action nodes + accumulate/export unification + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/Canvas.tsx | 8 +- web/src/lib/logic.ts | 214 ++++++++++++++++++++++++++++++------------- web/src/lib/types.ts | 5 + 3 files changed, 159 insertions(+), 68 deletions(-) diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index afdff8d..fb925fe 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -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(() => { diff --git a/web/src/lib/logic.ts b/web/src/lib/logic.ts index ee4bd86..40b4ec4 100644 --- a/web/src/lib/logic.ts +++ b/web/src/lib/logic.ts @@ -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(); // signal key → trigger node ids that react to it (threshold / change). private watchers = new Map(); - // 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>(); // Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal. private lastFire = new Map(); 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(), - })); - for (const s of series) for (const p of s.samples) s.map.set(p.t, p.v); - - const tsSet = new Set(); - 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; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 71362dd..8d33932 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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'