Add widget-control logic action and multi-column CSV / multi-field dialogs

Extend the panel logic editor with two capabilities:

- action.widget: drive a panel widget by id from a flow — enable/disable,
  show/hide, and (for plot widgets) clear, pause, or resume the live plot.
  Wired via a per-widget command store consumed by a Canvas WidgetView wrapper
  (disable overlay / hide) and PlotWidget (pause/clear), reset on panel unmount.
- action.export now emits multiple named arrays as CSV columns with per-column
  labels and an alignment mode (common/any/interpolate); dialogs support asking
  for and displaying multiple values per dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-19 07:51:18 +02:00
parent afefba3184
commit ba836f00e6
9 changed files with 668 additions and 102 deletions
+182 -25
View File
@@ -27,20 +27,32 @@
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';
// 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
// shown to the operator). A dialog may carry any number of both.
export interface DialogField {
type: 'input' | 'display';
label: string;
// input: the pre-filled default text; display: the resolved value to show.
value: string;
}
// A pending user-interaction dialog requested by an action.dialog.* node. The
// engine pushes one to `logicDialogs` and awaits the user's response; the
// LogicDialogs component renders it and calls `resolve` (the entered value for
// a set-point dialog, '' for info/error OK, or null for Cancel).
// LogicDialogs component renders it and calls `resolve` with the entered values
// for the input fields (in field order), or null for Cancel. Info/error
// dialogs have no inputs and resolve with an empty array on OK.
export interface DialogRequest {
id: string;
kind: 'info' | 'error' | 'setpoint';
title: string;
message: string;
defaultValue?: string;
resolve: (result: string | null) => void;
fields: DialogField[];
resolve: (entered: string[] | null) => void;
}
// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts.
@@ -74,6 +86,36 @@ function refKey(ds: string, name: string): string {
return `${ds}\0${name}`;
}
// A dialog field as configured on a node (before its value is resolved).
interface DialogFieldSpec {
type: 'input' | 'display';
label: string;
target?: string; // input: "ds:name" (or bare local var) to write on OK
default?: string; // input: expression pre-filling the input
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;
}
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
@@ -182,6 +224,9 @@ class LogicEngine {
this.watchers = new Map();
this.arrays = new Map();
this.lastFire = new Map();
// Restore every widget to its default state so reopening a panel (whose
// widget ids are stable) does not inherit disabled/hidden/paused flags.
resetWidgetCmds();
}
/** Fire every button-trigger node whose `name` param matches. */
@@ -223,12 +268,15 @@ class LogicEngine {
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
case 'action.dialog.info':
case 'action.dialog.error':
this.messageRefs(node.params.message ?? '').forEach(want); break;
case 'action.dialog.setpoint':
case 'action.dialog.setpoint': {
this.messageRefs(node.params.message ?? '').forEach(want);
this.messageRefs(node.params.title ?? '').forEach(want);
collectRefs(node.params.default ?? '').forEach(want);
for (const f of this.dialogFieldSpecs(node)) {
if (f.type === 'display') collectRefs(f.expr ?? '').forEach(want);
else collectRefs(f.default ?? '').forEach(want);
}
break;
}
}
}
@@ -359,7 +407,7 @@ class LogicEngine {
}
case 'action.export': {
this.exportArray((node.params.array ?? '').trim(), node.params.filename ?? '');
this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
await this.follow(node.id, 'out', ctx);
return;
}
@@ -379,32 +427,53 @@ class LogicEngine {
return;
}
case 'action.widget': {
const wid = (node.params.widget ?? '').trim();
if (wid) {
switch (node.params.op ?? '') {
case 'enable': setWidgetDisabled(wid, false); break;
case 'disable': setWidgetDisabled(wid, true); break;
case 'show': setWidgetHidden(wid, false); break;
case 'hide': setWidgetHidden(wid, true); break;
case 'pauseplot': setPlotPaused(wid, true); break;
case 'resumeplot': setPlotPaused(wid, false); break;
case 'clearplot': clearPlot(wid); break;
}
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.dialog.info':
case 'action.dialog.error': {
const specs = this.dialogFieldSpecs(node);
await this.showDialog({
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', ctx.resolve),
fields: this.dialogFieldViews(specs, ctx),
});
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.dialog.setpoint': {
const ref = parseRef(node.params.target ?? '');
const def = (node.params.default ?? '').trim()
? String(evalExpr(node.params.default ?? '', ctx.resolve))
: '';
const specs = this.dialogFieldSpecs(node);
const result = await this.showDialog({
kind: 'setpoint',
title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', ctx.resolve),
defaultValue: def,
fields: this.dialogFieldViews(specs, ctx),
});
// Cancel (null) aborts the flow; OK writes the value and continues.
// Cancel (null) aborts the flow; OK writes every input value and continues.
if (result === null) return;
const num = parseFloat(result);
if (ref && !isNaN(num)) wsClient.write(ref, num);
let i = 0;
for (const spec of specs) {
if (spec.type !== 'input') continue;
const ref = parseRef(spec.target ?? '');
const num = parseFloat(result[i++] ?? '');
if (ref && !isNaN(num)) wsClient.write(ref, num);
}
await this.follow(node.id, 'out', ctx);
return;
}
@@ -415,13 +484,62 @@ class LogicEngine {
}
}
// Dump a named array to a CSV file the browser downloads (timestamp + value).
private exportArray(name: string, filename: string): void {
if (!name) return;
const arr = this.arrays.get(name) ?? [];
const rows = arr.map(p => `${p.t},${new Date(p.t).toISOString()},${p.v}`);
const csv = 'timestamp_ms,iso,value\n' + rows.join('\n') + '\n';
const safe = (filename || name).replace(/[^a-z0-9_.-]/gi, '_');
// 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`.
private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
const raw = (node.params.columns ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((c: any) => ({ array: String(c?.array ?? '').trim(), label: String(c?.label ?? '').trim() }))
.filter(c => c.array);
}
} catch {}
}
const a = (node.params.array ?? '').trim();
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 {
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 safe = base.replace(/[^a-z0-9_.-]/gi, '_');
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
@@ -434,9 +552,48 @@ class LogicEngine {
// ── Dialogs (user interaction) ───────────────────────────────────────────────
// The field specs of a dialog node. Reads the JSON `fields` param (a list of
// { type:'input'|'display', label, target?, default?, expr? }); for a legacy
// set-point node with no `fields` it falls back to the single target/default.
private dialogFieldSpecs(node: LogicNode): DialogFieldSpec[] {
const raw = (node.params.fields ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
type: f?.type === 'display' ? 'display' : 'input',
label: String(f?.label ?? ''),
target: String(f?.target ?? ''),
default: String(f?.default ?? ''),
expr: String(f?.expr ?? ''),
}));
}
} catch {}
}
if (node.kind === 'action.dialog.setpoint') {
return [{ type: 'input', label: '', target: node.params.target ?? '', default: node.params.default ?? '', expr: '' }];
}
return [];
}
// Resolve a node's field specs into displayable views: display fields get their
// expression evaluated to text; input fields get their default expression
// evaluated to a pre-fill string ('' when no default is set).
private dialogFieldViews(specs: DialogFieldSpec[], ctx: RunCtx): DialogField[] {
return specs.map(s => {
if (s.type === 'display') {
const v = evalExpr(s.expr ?? '', ctx.resolve);
return { type: 'display' as const, label: s.label, value: isNaN(v) ? '?' : String(v) };
}
const def = (s.default ?? '').trim() ? String(evalExpr(s.default ?? '', ctx.resolve)) : '';
return { type: 'input' as const, label: s.label, value: def };
});
}
// Push a dialog request to the shared store and resolve when the user responds
// ('' on info/error OK, the entered text on a set-point OK, null on Cancel).
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string | null> {
// (the entered input values on OK, null on Cancel).
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string[] | null> {
return new Promise((resolve) => {
const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
const full: DialogRequest = {