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:
+38
-4
@@ -1,7 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import { logicEngine } from './lib/logic';
|
||||
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
|
||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||
import TextView from './widgets/TextView';
|
||||
import TextLabel from './widgets/TextLabel';
|
||||
@@ -42,6 +43,32 @@ interface CtxState {
|
||||
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 {
|
||||
iface: Interface | null;
|
||||
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;`}>
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
const inner = Comp
|
||||
? <Comp
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
onNavigate={onNavigate}
|
||||
@@ -141,7 +167,6 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
/>
|
||||
: (
|
||||
<div
|
||||
key={widget.id}
|
||||
class="unknown-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<WidgetView
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
>
|
||||
{inner}
|
||||
</WidgetView>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
<LogicEditor
|
||||
graph={iface.logic ?? { nodes: [], wires: [] }}
|
||||
onChange={handleLogicChange}
|
||||
widgets={iface.widgets}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
|
||||
@@ -13,10 +13,18 @@ export default function LogicDialogs() {
|
||||
}
|
||||
|
||||
function DialogModal({ req }: { req: DialogRequest; key?: string }) {
|
||||
const [val, setVal] = useState(req.defaultValue ?? '');
|
||||
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 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-header">{req.title || (req.kind === 'error' ? 'Error' : isSetpoint ? 'Set value' : 'Info')}</div>
|
||||
{req.message && <div class="logic-dialog-message">{req.message}</div>}
|
||||
{isSetpoint && (
|
||||
<input class="prop-input logic-dialog-input" type="number" autoFocus
|
||||
value={val}
|
||||
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={onKey} />
|
||||
)}
|
||||
{req.fields.map((f, i) => (
|
||||
<div class="logic-dialog-field" key={i}>
|
||||
{f.label && <label class="logic-dialog-label">{f.label}</label>}
|
||||
{f.type === 'input' ? (
|
||||
<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">
|
||||
{isSetpoint && <button class="panel-btn" onClick={cancel}>Cancel</button>}
|
||||
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
|
||||
|
||||
+274
-57
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
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 SignalInfo { name: string; }
|
||||
@@ -17,6 +17,8 @@ function splitRef(ref: string): { ds: string; name: string } {
|
||||
interface Props {
|
||||
graph: LogicGraph;
|
||||
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.
|
||||
statevars?: StateVar[];
|
||||
// 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.delay', label: 'Delay', params: { ms: '500' } },
|
||||
{ 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.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.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> = {
|
||||
@@ -81,6 +84,7 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
'action.export': 'Export CSV',
|
||||
'action.clear': 'Clear array',
|
||||
'action.log': 'Log',
|
||||
'action.widget': 'Widget control',
|
||||
'action.dialog.info': 'Info dialog',
|
||||
'action.dialog.error': 'Error 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}`;
|
||||
}
|
||||
|
||||
export default function LogicEditor({ graph, onChange, statevars, onStateVarsChange }: Props) {
|
||||
export default function LogicEditor({ graph, onChange, widgets = [], statevars, onStateVarsChange }: Props) {
|
||||
const nodes = graph.nodes;
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
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]);
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
@@ -644,21 +653,13 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.export' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Array name</label>
|
||||
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
|
||||
placeholder="e.g. data"
|
||||
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<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>
|
||||
<ExportEditor
|
||||
columns={parseExportColumns(selected)}
|
||||
align={selected.params.align ?? 'common'}
|
||||
filename={selected.params.filename ?? ''}
|
||||
onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
|
||||
onAlign={(a) => patchParams(selected.id, { align: a })}
|
||||
onFilename={(f) => patchParams(selected.id, { filename: f })} />
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.clear' && (
|
||||
@@ -687,6 +688,40 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
|
||||
</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') && (
|
||||
<p class="hint">{selected.kind === 'trigger.start'
|
||||
? '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.
|
||||
Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p>
|
||||
</div>
|
||||
<DialogFieldsEditor allowInput={false}
|
||||
fields={parseDialogFields(selected)}
|
||||
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.dialog.setpoint' && (() => {
|
||||
const { ds, name } = splitRef(selected.params.target ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Target data source</label>
|
||||
<SearchableSelect value={ds} options={dsOptions}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Title</label>
|
||||
<input class="prop-input" value={selected.params.title ?? ''}
|
||||
placeholder="Set value"
|
||||
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
{selected.kind === 'action.dialog.setpoint' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Title</label>
|
||||
<input class="prop-input" value={selected.params.title ?? ''}
|
||||
placeholder="Set value"
|
||||
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Message</label>
|
||||
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
|
||||
placeholder="Prompt shown above the fields. Embed values as {ds:name}."
|
||||
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
|
||||
</div>
|
||||
<DialogFieldsEditor allowInput={true}
|
||||
fields={parseDialogFields(selected)}
|
||||
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
|
||||
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
|
||||
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||
</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.
|
||||
function nodeSummary(n: LogicNode): string {
|
||||
switch (n.kind) {
|
||||
@@ -819,12 +1023,25 @@ function nodeSummary(n: LogicNode): string {
|
||||
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
|
||||
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
|
||||
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.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.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 '';
|
||||
}
|
||||
}
|
||||
|
||||
+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 = {
|
||||
|
||||
+18
-7
@@ -103,18 +103,28 @@ export interface StateVar {
|
||||
// action.delay — pause `ms` before continuing downstream (params: ms).
|
||||
// action.accumulate — evaluate `expr` and append the value (with a timestamp)
|
||||
// to the named in-memory data array (params: array, expr).
|
||||
// action.export — download the named array as a CSV file (params: array,
|
||||
// filename).
|
||||
// action.export — download one or more named arrays as a CSV file, one
|
||||
// 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.log — evaluate `expr` and log it to the browser console for
|
||||
// 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
|
||||
// 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:
|
||||
// title, message).
|
||||
// action.dialog.setpoint — prompt the user for a value; on OK write it to
|
||||
// `target` and continue, on Cancel abort the flow
|
||||
// (params: target, title, message, default).
|
||||
// title, message, fields).
|
||||
// action.dialog.setpoint — prompt the user for one or more values; on OK write
|
||||
// each input to its target and continue, on Cancel abort
|
||||
// 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.
|
||||
//
|
||||
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
|
||||
@@ -136,6 +146,7 @@ export type LogicNodeKind =
|
||||
| 'action.export'
|
||||
| 'action.clear'
|
||||
| 'action.log'
|
||||
| 'action.widget'
|
||||
| 'action.dialog.info'
|
||||
| 'action.dialog.error'
|
||||
| 'action.dialog.setpoint';
|
||||
|
||||
@@ -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
@@ -399,6 +399,16 @@ body {
|
||||
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 {
|
||||
@@ -1361,6 +1371,44 @@ body {
|
||||
}
|
||||
.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 {
|
||||
@@ -3570,7 +3618,25 @@ kbd {
|
||||
|
||||
.logic-dialog-input {
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import * as echarts from 'echarts';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import { getWidgetCmdStore } from '../lib/widgetCommands';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { formatValue } from '../lib/format';
|
||||
import { fftMag, resampleUniform } from '../lib/fft';
|
||||
@@ -374,8 +375,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
// ── Live streaming mode ────────────────────────────────────────────────
|
||||
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) => {
|
||||
const unsub = getSignalStore(sig).subscribe(sv => {
|
||||
if (paused) return;
|
||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||
const ts = new Date(sv.ts).getTime() / 1000;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user