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:
+182
-25
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user