Improving all side of app
This commit is contained in:
+54
-61
@@ -1,6 +1,6 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||
import { checkExpr } from './lib/expr';
|
||||
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
|
||||
|
||||
@@ -37,6 +37,11 @@ const NODE_W = 11.5 * REM; // node width
|
||||
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
|
||||
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
|
||||
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
|
||||
// Ports sit inside the node's padding box, offset from the node border-box origin
|
||||
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
|
||||
// offsets so wires meet the port centers, not their top edges.
|
||||
const BORDER = 1;
|
||||
const BORDER_TOP = 3;
|
||||
|
||||
interface PaletteEntry {
|
||||
kind: LogicNodeKind;
|
||||
@@ -114,11 +119,11 @@ function nodeHeight(kind: LogicNodeKind): number {
|
||||
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
||||
|
||||
// Port anchors in canvas coordinates.
|
||||
function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
||||
function inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
|
||||
function outAnchor(n: LogicNode, port: string) {
|
||||
const ports = outputs(n.kind);
|
||||
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
||||
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
|
||||
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
|
||||
}
|
||||
|
||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||
@@ -180,6 +185,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
return dsSignals[ds] ?? [];
|
||||
}
|
||||
|
||||
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
|
||||
// and a hook to lazily load all data sources' signals when a picker opens.
|
||||
function allSignalOptions(): SignalOption[] {
|
||||
const out: SignalOption[] = [];
|
||||
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
|
||||
return out;
|
||||
}
|
||||
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -520,19 +534,13 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
)}
|
||||
|
||||
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
|
||||
const { ds, name } = splitRef(selected.params.signal ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect value={ds} options={dsOptions}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals}
|
||||
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
|
||||
</div>
|
||||
{selected.kind === 'trigger.threshold' && (
|
||||
<div class="wizard-field wizard-field-row">
|
||||
@@ -573,7 +581,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
)}
|
||||
|
||||
@@ -597,36 +605,29 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
||||
)}
|
||||
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.write' && (() => {
|
||||
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>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
{selected.kind === 'action.write' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal / variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.delay' && (
|
||||
<div class="wizard-field">
|
||||
@@ -647,7 +648,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
|
||||
</Fragment>
|
||||
)}
|
||||
@@ -683,7 +684,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="Logs this value to the browser console — handy for debugging a flow." />
|
||||
</Fragment>
|
||||
)}
|
||||
@@ -747,7 +748,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
<DialogFieldsEditor allowInput={false}
|
||||
fields={parseDialogFields(selected)}
|
||||
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
@@ -768,7 +769,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
<DialogFieldsEditor allowInput={true}
|
||||
fields={parseDialogFields(selected)}
|
||||
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
|
||||
<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>
|
||||
@@ -796,17 +797,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
}
|
||||
|
||||
// Expression input with a signal-insert helper and live validation.
|
||||
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
|
||||
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onInsert: (ds: string, sig: string) => void;
|
||||
dsOptions: string[];
|
||||
signalOptions: (ds: string) => string[];
|
||||
loadSignals: (ds: string) => void;
|
||||
allSignals: SignalOption[];
|
||||
onOpenSignals: () => void;
|
||||
hint: string;
|
||||
}) {
|
||||
const [insDs, setInsDs] = useState('');
|
||||
const err = checkExpr(value);
|
||||
return (
|
||||
<div class="wizard-field">
|
||||
@@ -816,11 +815,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
|
||||
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
||||
{err && <p class="wizard-error">{err}</p>}
|
||||
<div class="flow-expr-insert">
|
||||
<SearchableSelect value={insDs} options={dsOptions}
|
||||
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
|
||||
<SearchableSelect value="" options={signalOptions(insDs)}
|
||||
onSelect={(sig) => onInsert(insDs, sig)}
|
||||
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
|
||||
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
|
||||
placeholder="+ insert signal"
|
||||
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
|
||||
</div>
|
||||
<p class="hint">{hint}</p>
|
||||
</div>
|
||||
@@ -934,13 +931,12 @@ function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
|
||||
function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
|
||||
allowInput: boolean;
|
||||
fields: DialogFieldSpec[];
|
||||
onChange: (fields: DialogFieldSpec[]) => void;
|
||||
dsOptions: string[];
|
||||
signalOptions: (ds: string) => string[];
|
||||
loadSignals: (ds: string) => void;
|
||||
allSignals: SignalOption[];
|
||||
onOpenSignals: () => void;
|
||||
}) {
|
||||
function patch(i: number, patch: Partial<DialogFieldSpec>) {
|
||||
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
|
||||
@@ -953,7 +949,6 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
|
||||
<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">
|
||||
@@ -972,11 +967,9 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
|
||||
{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'} />
|
||||
<SignalPicker value={f.target ?? ''} options={allSignals}
|
||||
onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
|
||||
onChange={(ref) => patch(i, { target: ref })} />
|
||||
</div>
|
||||
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
|
||||
value={f.default ?? ''} placeholder="default value (expression, optional)"
|
||||
|
||||
Reference in New Issue
Block a user