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
+38 -4
View File
@@ -1,7 +1,8 @@
import { h } 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 } from './lib/logic';
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';
import TextLabel from './widgets/TextLabel'; import TextLabel from './widgets/TextLabel';
@@ -42,6 +43,32 @@ interface CtxState {
signal: SignalRef | null; signal: SignalRef | null;
} }
// Wraps a single view-mode widget so panel logic (action.widget) can drive it:
// `hidden` removes it from the view, `disabled` dims it and blocks interaction
// via a transparent overlay (which still surfaces the right-click menu). Plot
// pause/clear are handled inside PlotWidget itself.
function WidgetView({ widget, children, onContextMenu }: {
widget: Widget;
children: any;
onContextMenu: (e: MouseEvent) => void;
key?: string;
}) {
const [cmd, setCmd] = useState<WidgetCmd>(() => getWidgetCmdStore(widget.id).get());
useEffect(() => getWidgetCmdStore(widget.id).subscribe(setCmd), [widget.id]);
if (cmd.hidden) return null;
if (!cmd.disabled) return children;
return (
<Fragment>
{children}
<div
class="widget-disable-overlay"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
/>
</Fragment>
);
}
interface Props { interface Props {
iface: Interface | null; iface: Interface | null;
onNavigate?: (interfaceId: string) => void; onNavigate?: (interfaceId: string) => void;
@@ -131,9 +158,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}> <div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => { {iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type]; const Comp = COMPONENTS[widget.type];
return Comp const inner = Comp
? <Comp ? <Comp
key={widget.id}
widget={widget} widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)} onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate} onNavigate={onNavigate}
@@ -141,7 +167,6 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
/> />
: ( : (
<div <div
key={widget.id}
class="unknown-widget" class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`} style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
title={`Unknown widget type: ${widget.type}`} title={`Unknown widget type: ${widget.type}`}
@@ -150,6 +175,15 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
<span class="unknown-label">{widget.type}</span> <span class="unknown-label">{widget.type}</span>
</div> </div>
); );
return (
<WidgetView
key={widget.id}
widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
>
{inner}
</WidgetView>
);
})} })}
</div> </div>
+1
View File
@@ -682,6 +682,7 @@ export default function EditMode({ initial, onDone }: Props) {
<LogicEditor <LogicEditor
graph={iface.logic ?? { nodes: [], wires: [] }} graph={iface.logic ?? { nodes: [], wires: [] }}
onChange={handleLogicChange} onChange={handleLogicChange}
widgets={iface.widgets}
statevars={iface.statevars} statevars={iface.statevars}
onStateVarsChange={handleStateVarsChange} onStateVarsChange={handleStateVarsChange}
/> />
+24 -8
View File
@@ -13,10 +13,18 @@ export default function LogicDialogs() {
} }
function DialogModal({ req }: { req: DialogRequest; key?: string }) { function DialogModal({ req }: { req: DialogRequest; key?: string }) {
const [val, setVal] = useState(req.defaultValue ?? '');
const isSetpoint = req.kind === 'setpoint'; const isSetpoint = req.kind === 'setpoint';
const inputIdx = req.fields
.map((f, i) => (f.type === 'input' ? i : -1))
.filter(i => i >= 0);
// Per-input editable values, keyed by field index, seeded from the prefill.
const [vals, setVals] = useState<Record<number, string>>(
() => Object.fromEntries(inputIdx.map(i => [i, req.fields[i].value]))
);
function ok() { req.resolve(isSetpoint ? val : ''); } function ok() {
req.resolve(inputIdx.map(i => vals[i] ?? ''));
}
function cancel() { req.resolve(null); } function cancel() { req.resolve(null); }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -29,12 +37,20 @@ function DialogModal({ req }: { req: DialogRequest; key?: string }) {
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog"> <div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
<div class="logic-dialog-header">{req.title || (req.kind === 'error' ? 'Error' : isSetpoint ? 'Set value' : 'Info')}</div> <div class="logic-dialog-header">{req.title || (req.kind === 'error' ? 'Error' : isSetpoint ? 'Set value' : 'Info')}</div>
{req.message && <div class="logic-dialog-message">{req.message}</div>} {req.message && <div class="logic-dialog-message">{req.message}</div>}
{isSetpoint && ( {req.fields.map((f, i) => (
<input class="prop-input logic-dialog-input" type="number" autoFocus <div class="logic-dialog-field" key={i}>
value={val} {f.label && <label class="logic-dialog-label">{f.label}</label>}
onInput={(e) => setVal((e.target as HTMLInputElement).value)} {f.type === 'input' ? (
onKeyDown={onKey} /> <input class="prop-input logic-dialog-input" type="number"
)} autoFocus={i === inputIdx[0]}
value={vals[i] ?? ''}
onInput={(e) => setVals(v => ({ ...v, [i]: (e.target as HTMLInputElement).value }))}
onKeyDown={onKey} />
) : (
<span class="logic-dialog-value">{f.value}</span>
)}
</div>
))}
<div class="logic-dialog-actions"> <div class="logic-dialog-actions">
{isSetpoint && <button class="panel-btn" onClick={cancel}>Cancel</button>} {isSetpoint && <button class="panel-btn" onClick={cancel}>Cancel</button>}
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button> <button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
+274 -57
View File
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect'; import SearchableSelect from './SearchableSelect';
import { checkExpr } from './lib/expr'; import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar } from './lib/types'; import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
interface DataSource { name: string; } interface DataSource { name: string; }
interface SignalInfo { name: string; } interface SignalInfo { name: string; }
@@ -17,6 +17,8 @@ function splitRef(ref: string): { ds: string; name: string } {
interface Props { interface Props {
graph: LogicGraph; graph: LogicGraph;
onChange: (graph: LogicGraph) => void; onChange: (graph: LogicGraph) => void;
// The panel's widgets, offered as targets for the Widget-control action.
widgets?: Widget[];
// Panel-local state variables, offered as quick write targets / signals. // Panel-local state variables, offered as quick write targets / signals.
statevars?: StateVar[]; statevars?: StateVar[];
// Edit local state variables directly from the logic editor (the layout-side // Edit local state variables directly from the logic editor (the layout-side
@@ -56,12 +58,13 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } }, { kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } }, { kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } }, { kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
{ kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } }, { kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"data","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } }, { kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } }, { kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.widget', label: 'Widget control', params: { widget: '', op: 'disable' } },
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } }, { kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
{ kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } }, { kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } },
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { target: '', title: 'Set value', message: '', default: '' } }, { kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
]; ];
const KIND_LABEL: Record<LogicNodeKind, string> = { const KIND_LABEL: Record<LogicNodeKind, string> = {
@@ -81,6 +84,7 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.export': 'Export CSV', 'action.export': 'Export CSV',
'action.clear': 'Clear array', 'action.clear': 'Clear array',
'action.log': 'Log', 'action.log': 'Log',
'action.widget': 'Widget control',
'action.dialog.info': 'Info dialog', 'action.dialog.info': 'Info dialog',
'action.dialog.error': 'Error dialog', 'action.dialog.error': 'Error dialog',
'action.dialog.setpoint': 'Set-point dialog', 'action.dialog.setpoint': 'Set-point dialog',
@@ -122,7 +126,7 @@ function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
} }
export default function LogicEditor({ graph, onChange, statevars, onStateVarsChange }: Props) { export default function LogicEditor({ graph, onChange, widgets = [], statevars, onStateVarsChange }: Props) {
const nodes = graph.nodes; const nodes = graph.nodes;
const wires = graph.wires; const wires = graph.wires;
@@ -182,9 +186,14 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') { if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
loadSignals(splitRef(selected.params.signal ?? '').ds); loadSignals(splitRef(selected.params.signal ?? '').ds);
} }
if (selected?.kind === 'action.write' || selected?.kind === 'action.dialog.setpoint') { if (selected?.kind === 'action.write') {
loadSignals(splitRef(selected.params.target ?? '').ds); loadSignals(splitRef(selected.params.target ?? '').ds);
} }
if (selected && selected.kind.startsWith('action.dialog.')) {
for (const f of parseDialogFields(selected)) {
if (f.type === 'input') loadSignals(splitRef(f.target ?? '').ds);
}
}
}, [selectedNode, selected?.kind]); }, [selectedNode, selected?.kind]);
// ── History ──────────────────────────────────────────────────────────────── // ── History ────────────────────────────────────────────────────────────────
@@ -644,21 +653,13 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
)} )}
{selected.kind === 'action.export' && ( {selected.kind === 'action.export' && (
<Fragment> <ExportEditor
<div class="wizard-field"> columns={parseExportColumns(selected)}
<label>Array name</label> align={selected.params.align ?? 'common'}
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names" filename={selected.params.filename ?? ''}
placeholder="e.g. data" onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} /> onAlign={(a) => patchParams(selected.id, { align: a })}
</div> onFilename={(f) => patchParams(selected.id, { filename: f })} />
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={selected.params.filename ?? ''}
placeholder="defaults to the array name"
onInput={(e) => patchParams(selected.id, { filename: (e.target as HTMLInputElement).value })} />
<p class="hint">Downloads the array as CSV (timestamp_ms, iso, value).</p>
</div>
</Fragment>
)} )}
{selected.kind === 'action.clear' && ( {selected.kind === 'action.clear' && (
@@ -687,6 +688,40 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
</Fragment> </Fragment>
)} )}
{selected.kind === 'action.widget' && (() => {
const op = selected.params.op ?? 'disable';
const plotOnly = op === 'clearplot' || op === 'pauseplot' || op === 'resumeplot';
const choices = plotOnly ? widgets.filter(w => w.type === 'plot') : widgets;
return (
<Fragment>
<div class="wizard-field">
<label>Action</label>
<select class="prop-input" value={op}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
<option value="show">Show</option>
<option value="hide">Hide</option>
<option value="clearplot">Clear plot</option>
<option value="pauseplot">Pause plot</option>
<option value="resumeplot">Resume plot</option>
</select>
</div>
<div class="wizard-field">
<label>Widget</label>
<select class="prop-input" value={selected.params.widget ?? ''}
onChange={(e) => patchParams(selected.id, { widget: (e.target as HTMLSelectElement).value })}>
<option value=""> select a widget </option>
{choices.map(w => (
<option value={w.id}>{widgetLabel(w)}</option>
))}
</select>
{plotOnly && <p class="hint">Only plot widgets are listed for this action.</p>}
</div>
</Fragment>
);
})()}
{(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && ( {(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && (
<p class="hint">{selected.kind === 'trigger.start' <p class="hint">{selected.kind === 'trigger.start'
? 'Fires once when the panel is opened — use it to initialise state or greet the operator.' ? 'Fires once when the panel is opened — use it to initialise state or greet the operator.'
@@ -709,44 +744,35 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
<p class="hint">Waits for the operator to dismiss the dialog before continuing. <p class="hint">Waits for the operator to dismiss the dialog before continuing.
Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p> Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p>
</div> </div>
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
</Fragment> </Fragment>
)} )}
{selected.kind === 'action.dialog.setpoint' && (() => { {selected.kind === 'action.dialog.setpoint' && (
const { ds, name } = splitRef(selected.params.target ?? ''); <Fragment>
return ( <div class="wizard-field">
<Fragment> <label>Title</label>
<div class="wizard-field"> <input class="prop-input" value={selected.params.title ?? ''}
<label>Target data source</label> placeholder="Set value"
<SearchableSelect value={ds} options={dsOptions} onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} /> </div>
</div> <div class="wizard-field">
<div class="wizard-field"> <label>Message</label>
<label>Target signal</label> <textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
<SearchableSelect value={name} options={signalOptions(ds)} placeholder="Prompt shown above the fields. Embed values as {ds:name}."
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })} onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
placeholder={ds ? 'Search signals…' : 'Select data source first'} /> </div>
</div> <DialogFieldsEditor allowInput={true}
<div class="wizard-field"> fields={parseDialogFields(selected)}
<label>Title</label> onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
<input class="prop-input" value={selected.params.title ?? ''} dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
placeholder="Set value" <p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} /> target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</div> </Fragment>
<div class="wizard-field"> )}
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="Prompt shown above the input. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
<ExprField label="Default value (expression)" value={selected.params.default ?? ''}
onChange={(v) => patchParams(selected.id, { default: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'default', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Pre-fills the input (e.g. the current set-point {local:sp}). The operator can edit it; OK writes the entered number to the target, Cancel aborts the flow." />
</Fragment>
);
})()}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button> <button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment> </Fragment>
@@ -801,6 +827,184 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
); );
} }
// ── Export CSV: multi-column editor ──────────────────────────────────────────
interface ExportColumn { array: string; label: string; }
// The columns configured on an action.export node. Reads the JSON `columns`
// param; falls back to the legacy single `array` param (so old panels migrate
// transparently the first time the node is edited).
function parseExportColumns(n: LogicNode): ExportColumn[] {
const raw = (n.params.columns ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((c: any) => ({ array: String(c?.array ?? ''), label: String(c?.label ?? '') }));
}
} catch {}
}
const a = (n.params.array ?? '').trim();
return [{ array: a, label: '' }];
}
const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
{ value: 'common', label: 'Common rows', hint: 'Only timestamps present in every array (intersection).' },
{ value: 'any', label: 'All rows', hint: 'Union of all timestamps; missing cells are left blank.' },
{ value: 'interpolate', label: 'Interpolate', hint: 'Union of all timestamps; missing cells linearly interpolated.' },
];
function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename }: {
columns: ExportColumn[];
align: string;
filename: string;
onColumns: (cols: ExportColumn[]) => void;
onAlign: (a: string) => void;
onFilename: (f: string) => void;
}) {
function patch(i: number, patch: Partial<ExportColumn>) {
onColumns(columns.map((c, j) => (j === i ? { ...c, ...patch } : c)));
}
const alignHint = ALIGN_OPTS.find(o => o.value === align)?.hint ?? '';
return (
<Fragment>
<div class="wizard-field">
<label>Columns (arrays)</label>
{columns.map((c, i) => (
<div key={i} class="flow-row-edit">
<input class="prop-input" value={c.array} list="flow-array-names"
placeholder="array name"
onInput={(e) => patch(i, { array: (e.target as HTMLInputElement).value })} />
<input class="prop-input" value={c.label}
placeholder="column label (optional)"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove column"
disabled={columns.length <= 1}
onClick={() => onColumns(columns.filter((_, j) => j !== i))}></button>
</div>
))}
<button class="panel-btn flow-row-add" onClick={() => onColumns([...columns, { array: '', label: '' }])}>+ Add column</button>
</div>
{columns.length > 1 && (
<div class="wizard-field">
<label>Alignment</label>
<select class="prop-select" value={align} onChange={(e) => onAlign((e.target as HTMLSelectElement).value)}>
{ALIGN_OPTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p class="hint">{alignHint}</p>
</div>
)}
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={filename}
placeholder="defaults to the column labels"
onInput={(e) => onFilename((e.target as HTMLInputElement).value)} />
<p class="hint">Downloads a CSV with columns: timestamp_ms, iso, then one column per array.</p>
</div>
</Fragment>
);
}
// ── Dialogs: multi-field editor ──────────────────────────────────────────────
interface DialogFieldSpec { type: 'input' | 'display'; label: string; target?: string; default?: string; expr?: string; }
// The fields configured on an action.dialog.* node. Reads the JSON `fields`
// param; for a legacy set-point node it synthesises a single input from the old
// target/default params so existing panels migrate transparently.
function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
const raw = (n.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 (n.kind === 'action.dialog.setpoint') {
return [{ type: 'input', label: '', target: n.params.target ?? '', default: n.params.default ?? '', expr: '' }];
}
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
}
function add(type: 'input' | 'display') {
onChange([...fields, { type, label: '', target: '', default: '', expr: '' }]);
}
return (
<div class="wizard-field">
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
{allowInput ? (
<select class="prop-select" value={f.type}
onChange={(e) => patch(i, { type: (e.target as HTMLSelectElement).value as 'input' | 'display' })}>
<option value="input">Input</option>
<option value="display">Display</option>
</select>
) : <span class="flow-field-kind">Display</span>}
<input class="prop-input" value={f.label} placeholder="label"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove field"
onClick={() => onChange(fields.filter((_, j) => j !== i))}></button>
</div>
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
</div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)"
onInput={(e) => patch(i, { default: (e.target as HTMLInputElement).value })} />
</Fragment>
) : (
<input class={`prop-input${checkExpr(f.expr ?? '') ? ' prop-input-error' : ''}`}
value={f.expr ?? ''} placeholder="value to show (expression, e.g. {stub:level})"
onInput={(e) => patch(i, { expr: (e.target as HTMLInputElement).value })} />
)}
</div>
);
})}
<div class="flow-field-add">
{allowInput && <button class="panel-btn flow-row-add" onClick={() => add('input')}>+ Add input</button>}
<button class="panel-btn flow-row-add" onClick={() => add('display')}>+ Add value to show</button>
</div>
</div>
);
}
// A human-friendly label for a widget in the Widget-control picker: its type
// plus its first signal (or id) so panels with many widgets stay legible.
function widgetLabel(w: Widget): string {
const sig = w.signals[0] ? `${w.signals[0].ds}:${w.signals[0].name}` : '';
return `${w.type}${sig ? ' · ' + sig : ''} (${w.id})`;
}
// One-line summary shown in a node block's body. // One-line summary shown in a node block's body.
function nodeSummary(n: LogicNode): string { function nodeSummary(n: LogicNode): string {
switch (n.kind) { switch (n.kind) {
@@ -819,12 +1023,25 @@ function nodeSummary(n: LogicNode): string {
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`; case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`; case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`; case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`;
case 'action.export': return `export ${n.params.array || '?'} → csv`; case 'action.export': {
const cols = parseExportColumns(n).filter(c => c.array);
const names = cols.map(c => c.label || c.array).join(', ');
return `export ${names || '?'} → csv`;
}
case 'action.clear': return `clear ${n.params.array || '?'}`; case 'action.clear': return `clear ${n.params.array || '?'}`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`; case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.widget': return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`; case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.error': return `error: ${n.params.title || n.params.message || '…'}`; case 'action.dialog.error': return `error: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.setpoint': return `set ${n.params.target || '?'} (ask)`; case 'action.dialog.setpoint': {
const fs = parseDialogFields(n);
const ins = fs.filter(f => f.type === 'input').length;
const shows = fs.filter(f => f.type === 'display').length;
const parts: string[] = [];
if (ins) parts.push(`ask ${ins}`);
if (shows) parts.push(`show ${shows}`);
return parts.length ? parts.join(', ') : 'ask…';
}
default: return ''; default: return '';
} }
} }
+182 -25
View File
@@ -27,20 +27,32 @@
import { wsClient } from './ws'; 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 type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types'; import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr'; 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 // A pending user-interaction dialog requested by an action.dialog.* node. The
// engine pushes one to `logicDialogs` and awaits the user's response; the // engine pushes one to `logicDialogs` and awaits the user's response; the
// LogicDialogs component renders it and calls `resolve` (the entered value for // LogicDialogs component renders it and calls `resolve` with the entered values
// a set-point dialog, '' for info/error OK, or null for Cancel). // 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 { export interface DialogRequest {
id: string; id: string;
kind: 'info' | 'error' | 'setpoint'; kind: 'info' | 'error' | 'setpoint';
title: string; title: string;
message: string; message: string;
defaultValue?: string; fields: DialogField[];
resolve: (result: string | null) => void; resolve: (entered: string[] | null) => void;
} }
// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts. // 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}`; 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 } 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
@@ -182,6 +224,9 @@ class LogicEngine {
this.watchers = new Map(); this.watchers = new Map();
this.arrays = new Map(); this.arrays = new Map();
this.lastFire = 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. */ /** 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.log': collectRefs(node.params.expr ?? '').forEach(want); break;
case 'action.dialog.info': case 'action.dialog.info':
case 'action.dialog.error': 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.message ?? '').forEach(want);
this.messageRefs(node.params.title ?? '').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; break;
}
} }
} }
@@ -359,7 +407,7 @@ class LogicEngine {
} }
case 'action.export': { 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); await this.follow(node.id, 'out', ctx);
return; return;
} }
@@ -379,32 +427,53 @@ class LogicEngine {
return; 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.info':
case 'action.dialog.error': { case 'action.dialog.error': {
const specs = this.dialogFieldSpecs(node);
await this.showDialog({ await this.showDialog({
kind: node.kind === 'action.dialog.error' ? 'error' : 'info', kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
title: this.interpolate(node.params.title ?? '', ctx.resolve), title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', ctx.resolve), message: this.interpolate(node.params.message ?? '', ctx.resolve),
fields: this.dialogFieldViews(specs, ctx),
}); });
await this.follow(node.id, 'out', ctx); await this.follow(node.id, 'out', ctx);
return; return;
} }
case 'action.dialog.setpoint': { case 'action.dialog.setpoint': {
const ref = parseRef(node.params.target ?? ''); const specs = this.dialogFieldSpecs(node);
const def = (node.params.default ?? '').trim()
? String(evalExpr(node.params.default ?? '', ctx.resolve))
: '';
const result = await this.showDialog({ const result = await this.showDialog({
kind: 'setpoint', kind: 'setpoint',
title: this.interpolate(node.params.title ?? '', ctx.resolve), title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', 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; if (result === null) return;
const num = parseFloat(result); let i = 0;
if (ref && !isNaN(num)) wsClient.write(ref, num); 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); await this.follow(node.id, 'out', ctx);
return; return;
} }
@@ -415,13 +484,62 @@ class LogicEngine {
} }
} }
// Dump a named array to a CSV file the browser downloads (timestamp + value). // The columns an action.export node should emit. Reads the JSON `columns`
private exportArray(name: string, filename: string): void { // param (a list of { array, label }); falls back to the legacy single `array`.
if (!name) return; private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
const arr = this.arrays.get(name) ?? []; const raw = (node.params.columns ?? '').trim();
const rows = arr.map(p => `${p.t},${new Date(p.t).toISOString()},${p.v}`); if (raw) {
const csv = 'timestamp_ms,iso,value\n' + rows.join('\n') + '\n'; try {
const safe = (filename || name).replace(/[^a-z0-9_.-]/gi, '_'); 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 fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -434,9 +552,48 @@ class LogicEngine {
// ── Dialogs (user interaction) ─────────────────────────────────────────────── // ── 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 // 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). // (the entered input values on OK, null on Cancel).
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string | null> { private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string[] | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = 'dlg_' + Math.random().toString(36).slice(2, 9); const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
const full: DialogRequest = { const full: DialogRequest = {
+18 -7
View File
@@ -103,18 +103,28 @@ export interface StateVar {
// action.delay — pause `ms` before continuing downstream (params: ms). // action.delay — pause `ms` before continuing downstream (params: ms).
// action.accumulate — evaluate `expr` and append the value (with a timestamp) // action.accumulate — evaluate `expr` and append the value (with a timestamp)
// to the named in-memory data array (params: array, expr). // to the named in-memory data array (params: array, expr).
// action.export — download the named array as a CSV file (params: array, // action.export — download one or more named arrays as a CSV file, one
// filename). // column per array (params: columns = JSON list of
// {array,label}, align 'common'|'any'|'interpolate',
// filename). Legacy single-array nodes use `array`.
// action.clear — empty the named array (params: array). // action.clear — empty the named array (params: array).
// action.log — evaluate `expr` and log it to the browser console for // action.log — evaluate `expr` and log it to the browser console for
// debugging a flow (params: expr, label). // debugging a flow (params: expr, label).
// action.widget — drive a panel widget by id (params: widget, op). `op` is
// one of enable/disable (interaction), show/hide
// (visibility), or for plot widgets clearplot (one-shot
// buffer clear) / pauseplot / resumeplot (live stream).
// action.dialog.info — show an info dialog and wait for the user to dismiss // action.dialog.info — show an info dialog and wait for the user to dismiss
// it before continuing (params: title, message). // it before continuing (params: title, message, fields).
// action.dialog.error — like dialog.info but styled as an error (params: // action.dialog.error — like dialog.info but styled as an error (params:
// title, message). // title, message, fields).
// action.dialog.setpoint — prompt the user for a value; on OK write it to // action.dialog.setpoint — prompt the user for one or more values; on OK write
// `target` and continue, on Cancel abort the flow // each input to its target and continue, on Cancel abort
// (params: target, title, message, default). // the flow (params: title, message, fields).
// All dialogs may carry a `fields` param: a JSON list of
// {type:'input'|'display', label, target, default, expr}. `input` fields
// prompt for a value written to `target`; `display` fields show the resolved
// `expr`. Legacy set-point nodes use `target`/`default` for a single input.
// Dialog `message`/`title` text may embed signal values as {ds:name} tokens. // Dialog `message`/`title` text may embed signal values as {ds:name} tokens.
// //
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel- // Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
@@ -136,6 +146,7 @@ export type LogicNodeKind =
| 'action.export' | 'action.export'
| 'action.clear' | 'action.clear'
| 'action.log' | 'action.log'
| 'action.widget'
| 'action.dialog.info' | 'action.dialog.info'
| 'action.dialog.error' | 'action.dialog.error'
| 'action.dialog.setpoint'; | 'action.dialog.setpoint';
+47
View File
@@ -0,0 +1,47 @@
// Per-widget command bus.
//
// Panel logic (logic.ts) runs in view mode and can drive individual widgets via
// the `action.widget` node: enable/disable, show/hide, and — for plot widgets —
// clear or pause/resume the live stream. Each widget id gets a small reactive
// store; widget components subscribe to their own store (Canvas wraps every
// widget in a <WidgetView> that honours disabled/hidden, and PlotWidget reacts
// to paused/clear). The engine writes commands through the setters below.
import { writable, type Writable } from './store';
export interface WidgetCmd {
disabled: boolean; // interaction blocked + dimmed
hidden: boolean; // removed from the view
paused: boolean; // plot: stop ingesting live samples
clearNonce: number; // plot: bumped to request a one-shot buffer clear
}
const DEFAULT: WidgetCmd = { disabled: false, hidden: false, paused: false, clearNonce: 0 };
const stores = new Map<string, Writable<WidgetCmd>>();
export function getWidgetCmdStore(id: string): Writable<WidgetCmd> {
let s = stores.get(id);
if (!s) { s = writable({ ...DEFAULT }); stores.set(id, s); }
return s;
}
export function setWidgetDisabled(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, disabled: v }));
}
export function setWidgetHidden(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, hidden: v }));
}
export function setPlotPaused(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, paused: v }));
}
export function clearPlot(id: string): void {
getWidgetCmdStore(id).update(c => ({ ...c, clearNonce: c.clearNonce + 1 }));
}
// Reset every widget back to its default state. Called when a panel unmounts so
// that re-opening the same panel (whose widget ids are stable) starts fresh
// rather than inheriting disabled/hidden/paused flags from the previous view.
export function resetWidgetCmds(): void {
for (const s of stores.values()) s.set({ ...DEFAULT });
}
+67 -1
View File
@@ -399,6 +399,16 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* Overlay shown over a widget disabled via action.widget logic: dims it and
blocks interaction while still surfacing the right-click context menu. */
.widget-disable-overlay {
position: absolute;
z-index: 10;
background: rgba(20, 25, 35, 0.45);
cursor: not-allowed;
box-sizing: border-box;
}
/* ── Unknown widget ────────────────────────────────────────────────────────── */ /* ── Unknown widget ────────────────────────────────────────────────────────── */
.unknown-widget { .unknown-widget {
@@ -1361,6 +1371,44 @@ body {
} }
.flow-localvar-actions .panel-btn { flex: 1; } .flow-localvar-actions .panel-btn { flex: 1; }
/* Repeated rows in inspector editors (export columns, dialog fields). */
.flow-row-edit {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
}
.flow-row-edit .prop-input { flex: 1; min-width: 0; }
.flow-row-add {
width: 100%;
margin-top: 0.15rem;
}
.flow-field-edit {
border: 1px solid #2a3548;
border-radius: 4px;
padding: 0.4rem;
margin-bottom: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.flow-field-head {
display: flex;
align-items: center;
gap: 0.35rem;
}
.flow-field-head .prop-input { flex: 1; min-width: 0; }
.flow-field-kind {
font-size: 0.75rem;
color: #94a3b8;
flex: 0 0 auto;
}
.flow-field-add {
display: flex;
gap: 0.35rem;
}
/* ── History pane ───────────────────────────────────────────────────────── */ /* ── History pane ───────────────────────────────────────────────────────── */
.history-pane { .history-pane {
@@ -3570,7 +3618,25 @@ kbd {
.logic-dialog-input { .logic-dialog-input {
width: 100%; width: 100%;
margin-bottom: 0.75rem; margin-bottom: 0;
}
/* A single field row (input or display) in a multi-value dialog. */
.logic-dialog-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.6rem;
}
.logic-dialog-label {
font-size: 0.8rem;
color: #94a3b8;
}
.logic-dialog-value {
font-size: 0.9rem;
font-weight: 600;
color: #e2e8f0;
padding: 0.2rem 0;
} }
.logic-dialog-actions { .logic-dialog-actions {
+17
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import uPlot from 'uplot'; import uPlot from 'uplot';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores'; import { getSignalStore } from '../lib/stores';
import { getWidgetCmdStore } from '../lib/widgetCommands';
import { wsClient } from '../lib/ws'; import { wsClient } from '../lib/ws';
import { formatValue } from '../lib/format'; import { formatValue } from '../lib/format';
import { fftMag, resampleUniform } from '../lib/fft'; import { fftMag, resampleUniform } from '../lib/fft';
@@ -374,8 +375,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// ── Live streaming mode ──────────────────────────────────────────────── // ── Live streaming mode ────────────────────────────────────────────────
setHistStatus('idle'); setHistStatus('idle');
// Honour action.widget logic commands: `paused` suspends live ingestion and
// a bumped `clearNonce` empties the buffers + redraws once.
const cmdStore = getWidgetCmdStore(widget.id);
let paused = cmdStore.get().paused;
let lastClear = cmdStore.get().clearNonce;
unsubs.push(cmdStore.subscribe(cmd => {
paused = cmd.paused;
if (cmd.clearNonce !== lastClear) {
lastClear = cmd.clearNonce;
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
if (uplot) uplot.setData(uplotData(timeWindow));
if (echart) echart.setOption(echartsOption(), { notMerge: true });
}
}));
signals.forEach((sig: SignalRef & { color?: string }, i) => { signals.forEach((sig: SignalRef & { color?: string }, i) => {
const unsub = getSignalStore(sig).subscribe(sv => { const unsub = getSignalStore(sig).subscribe(sv => {
if (paused) return;
if (sv.value === null || sv.value === undefined || sv.ts === null) return; if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000; const ts = new Date(sv.ts).getTime() / 1000;