Files
uopi/web/src/PropertiesPane.tsx
T
Martino Ferrari 73fcbe7b28 Improved plot view
2026-06-22 00:30:15 +02:00

446 lines
20 KiB
TypeScript

import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { Widget, Interface } from './lib/types';
interface Props {
selected: Widget | null;
multiCount: number;
iface: Interface;
onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void;
width?: number;
}
function Field({ label, children }: { label: string; children: any }) {
return (
<div class="prop-field">
<label class="prop-label">{label}</label>
{children}
</div>
);
}
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [local, setLocal] = useState(value || '');
// Sync when external value changes (e.g. different widget selected)
useEffect(() => {
setLocal(value || '');
}, [value]);
return (
<input
class="prop-input"
value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onCommit((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).blur();
}
}}
/>
);
}
// Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
// Widgets where the user can set a numeric format string
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
// Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']);
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
// Config sets are only needed by the Config Selector widget; fetch lazily the
// first time one is selected.
useEffect(() => {
if (selected?.type !== 'configselect' || configSets.length > 0) return;
fetch('/api/v1/config/sets')
.then(r => r.ok ? r.json() : [])
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
.catch(() => {});
}, [selected?.type]);
function setOpt(key: string, value: string) {
if (!selected) return;
onChange({ ...selected, options: { ...selected.options, [key]: value } });
}
function setProp(key: keyof Widget, value: any) {
if (!selected) return;
onChange({ ...selected, [key]: value } as Widget);
}
function removeSignal(idx: number) {
if (!selected) return;
const signals = selected.signals.filter((_, i) => i !== idx);
onChange({ ...selected, signals });
}
const w = selected;
return (
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style={`border-left:1px solid #2d3748;border-right:none;${!collapsed && width ? `width:${width}px;min-width:${width}px;` : ''}`}>
<div class="panel-header">
{!collapsed && <span class="panel-title">Properties</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
{collapsed ? '◀' : '▶'}
</button>
</div>
{!collapsed && (
<div class="props-body">
{/* Canvas-level properties */}
<div class="props-section">
<div class="props-section-title">{iface.kind === 'plot' ? 'Plot panel' : 'Canvas'}</div>
<Field label="Name">
<TextInput
value={iface.name}
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
/>
</Field>
{/* Plot panels fill the viewport via the split layout — fixed px size
is not meaningful, so width/height are hidden. */}
{iface.kind !== 'plot' && (
<div>
<Field label="Width">
<input
class="prop-input prop-input-num"
type="number"
value={iface.w}
min={100}
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
/>
</Field>
<Field label="Height">
<input
class="prop-input prop-input-num"
type="number"
value={iface.h}
min={100}
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
/>
</Field>
</div>
)}
</div>
{w && (
<div class="props-section" key={w.id}>
<div class="props-section-title">{w.type}</div>
{/* Position / size */}
<Field label="X">
<input class="prop-input prop-input-num" type="number" value={w.x}
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field>
<Field label="Y">
<input class="prop-input prop-input-num" type="number" value={w.y}
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field>
<Field label="Width">
<input class="prop-input prop-input-num" type="number" value={w.w} min={10}
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field>
<Field label="Height">
<input class="prop-input prop-input-num" type="number" value={w.h} min={10}
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field>
{/* Signals */}
{MULTI_SIGNAL_WIDGETS.has(w.type) ? (
<Field label="Signals">
<div class="prop-signals-list">
{w.signals.length === 0 && <span class="hint">No signals</span>}
{w.signals.map((s, i) => (
<div key={i} class="prop-signal-row">
<span class="prop-sig">{s.ds}:{s.name}</span>
<button
class="icon-btn prop-sig-remove"
title="Remove signal"
onClick={() => removeSignal(i)}
></button>
</div>
))}
</div>
</Field>
) : w.signals.length > 0 ? (
<Field label="Signal">
<span class="prop-sig">{w.signals[0].ds}:{w.signals[0].name}</span>
</Field>
) : null}
{/* ── Common label/unit overrides (issue #8) ──────────────────── */}
{/* Label: button and textlabel use it as display text;
signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
{LABEL_WIDGETS.has(w.type) ? (
<div class="prop-field-col">
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
<span class="prop-hint">Empty = signal name</span>
</div>
) : (
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
)}
</Field>
)}
{/* Unit override for signal widgets */}
{UNIT_WIDGETS.has(w.type) && (
<Field label="Unit">
<div class="prop-field-col">
<TextInput value={w.options['unit'] ?? ''} onCommit={(v) => setOpt('unit', v)} />
<span class="prop-hint">Empty = from meta · "none" = hide</span>
</div>
</Field>
)}
{/* Format string for numeric display */}
{FORMAT_WIDGETS.has(w.type) && (
<Field label="Format">
<div class="prop-field-col">
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
</div>
</Field>
)}
{/* ── Type-specific options ──────────────────────────────────── */}
{(w.type === 'gauge' || w.type === 'barh' || w.type === 'barv') && (
<div>
<Field label="Min">
<TextInput value={w.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
</Field>
<Field label="Max">
<TextInput value={w.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
</Field>
</div>
)}
{w.type === 'led' && (
<Field label="Condition">
<TextInput value={w.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
</Field>
)}
{w.type === 'setvalue' && (
<div>
<Field label="Min">
<TextInput value={w.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
</Field>
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
{w.type === 'toggle' && (
<div>
<Field label="On value">
<TextInput value={w.options['onValue'] ?? '1'} onCommit={(v) => setOpt('onValue', v)} />
</Field>
<Field label="Off value">
<TextInput value={w.options['offValue'] ?? '0'} onCommit={(v) => setOpt('offValue', v)} />
</Field>
<Field label="On label">
<TextInput value={w.options['onLabel'] ?? ''} onCommit={(v) => setOpt('onLabel', v)} />
</Field>
<Field label="Off label">
<TextInput value={w.options['offLabel'] ?? ''} onCommit={(v) => setOpt('offLabel', v)} />
</Field>
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
{/* Button options (issue #2) */}
{w.type === 'button' && (
<div>
<Field label="Send value">
<TextInput value={w.options['value'] ?? '1'} onCommit={(v) => setOpt('value', v)} />
</Field>
<Field label="Mode">
<select class="prop-select" value={w.options['mode'] ?? 'oneshot'}
onChange={(e) => setOpt('mode', (e.target as HTMLSelectElement).value)}>
<option value="oneshot">One-shot (set once)</option>
<option value="setReset">Set-reset (auto-revert)</option>
<option value="persistent">Persistent (toggle / monitor)</option>
</select>
</Field>
{(w.options['mode'] === 'setReset' || w.options['mode'] === 'persistent') && (
<Field label="Reset value">
<TextInput value={w.options['resetValue'] ?? '0'} onCommit={(v) => setOpt('resetValue', v)} />
</Field>
)}
{w.options['mode'] === 'setReset' && (
<Field label="Reset after (s)">
<TextInput value={w.options['resetTime'] ?? '1'} onCommit={(v) => setOpt('resetTime', v)} />
</Field>
)}
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
<Field label="Action">
<div class="prop-field-col">
<select
class="prop-input"
value={w.options['action'] ?? ''}
onChange={(e) => setOpt('action', (e.target as HTMLSelectElement).value)}
>
<option value="">(none)</option>
{(iface.logic?.nodes ?? [])
.filter(n => n.kind === 'trigger.button')
.map(n => (
<option key={n.id} value={n.params.name ?? ''}>{n.params.name || '(unnamed)'}</option>
))}
</select>
<span class="prop-hint">Fires a Button trigger node (define flows in the Logic tab)</span>
</div>
</Field>
</div>
)}
{/* Plot options */}
{w.type === 'plot' && (
<div>
<Field label="Plot type">
<select
class="prop-select"
value={w.options['plotType'] ?? 'timeseries'}
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
>
<option value="timeseries">Time-series</option>
<option value="histogram">Histogram</option>
<option value="bar">Bar chart</option>
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
<option value="waveform">Waveform (array)</option>
</select>
</Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
<Field label="Time window (s)">
<TextInput value={w.options['timeWindow'] ?? '60'} onCommit={(v) => setOpt('timeWindow', v)} />
</Field>
)}
<Field label="Y min">
<TextInput value={w.options['yMin'] ?? 'auto'} onCommit={(v) => setOpt('yMin', v)} />
</Field>
<Field label="Y max">
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
</Field>
<Field label="Y format">
<div class="prop-field-col">
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
</div>
</Field>
<Field label="Legend">
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
<option value="bottom">Bottom</option>
<option value="top">Top</option>
<option value="none">None</option>
</select>
</Field>
</div>
)}
{w.type === 'textlabel' && (
<div>
<Field label="Font size">
<TextInput value={w.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
</Field>
<Field label="Color">
<TextInput value={w.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
</Field>
</div>
)}
{w.type === 'image' && (
<Field label="URL">
<TextInput value={w.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
</Field>
)}
{w.type === 'link' && (
<div>
<Field label="Target">
<TextInput value={w.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
</Field>
<Field label="Label">
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
</Field>
</div>
)}
{w.type === 'configselect' && (
<div>
<Field label="Label">
<TextInput value={w.options['label'] ?? 'Config'} onCommit={(v) => setOpt('label', v)} />
</Field>
<Field label="Config set">
<select class="prop-select" value={w.options['set'] ?? ''}
onChange={(e) => setOpt('set', (e.target as HTMLSelectElement).value)}>
<option value="">choose</option>
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
</select>
</Field>
<Field label="Output variable">
<div class="prop-field-col">
<select class="prop-input" value={w.options['output'] ?? ''}
onChange={(e) => setOpt('output', (e.target as HTMLSelectElement).value)}>
<option value="">(none)</option>
{(iface.statevars ?? []).map(v => (
<option key={v.name} value={v.name}>{v.name}</option>
))}
</select>
<span class="prop-hint">A panel variable (define one of type "string" in the Variables tab); receives the selected instance id.</span>
</div>
</Field>
<Field label="Instance subset">
<div class="prop-field-col">
<TextInput value={w.options['instances'] ?? ''} onCommit={(v) => setOpt('instances', v)} />
<span class="prop-hint">Comma-separated instance ids to show. Empty = all instances of the set.</span>
</div>
</Field>
</div>
)}
</div>
)}
{!w && multiCount > 1 && (
<p class="hint" style="padding:0.75rem;">{multiCount} widgets selected.<br/>Use align/distribute tools in the toolbar.</p>
)}
{!w && multiCount === 0 && (
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
)}
</div>
)}
</aside>
);
}