Improving all side of app

This commit is contained in:
Martino Ferrari
2026-06-20 14:28:28 +02:00
parent 901b87d407
commit 446de7f1ee
33 changed files with 1758 additions and 389 deletions
+54 -61
View File
@@ -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)"