feat(widgets): array source modes for plot, table, multi-LED

- PlotWidget: 1-D array values (local vars + EPICS waveforms) are
  routed through the waveform rendering path regardless of configured
  plotType; nested 2-D arrays are skipped gracefully.  effectivePlotType
  switches to 'waveform' whenever any waveforms[] slot is populated.
- TableWidget: new sourceMode:'array' option renders one <tr> per array
  element (index in 'name' col, formatted value in 'value' col).  For
  2-D (elem:'array') rows, colIndices option maps column positions to
  inner-array positions.  Scalar/multi-signal mode unchanged.
- MultiLed: new sourceMode:'array' option (or auto-detect when value is
  already an array) renders one LED per element with live-length tracking;
  elem truthiness controls lit state; per-element label/color overrides
  supported; meta.elem==='bool' awareness noted in code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-24 13:22:20 +02:00
parent 603574f86f
commit 91661485ae
3 changed files with 165 additions and 13 deletions
+49 -5
View File
@@ -1,7 +1,7 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore } from '../lib/stores';
import type { Widget, SignalValue } from '../lib/types';
import { getSignalStore, getMetaStore } from '../lib/stores';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
@@ -14,13 +14,16 @@ interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function MultiLed({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsub = getSignalStore(sigRef).subscribe(setSv);
return unsub;
const uv = getSignalStore(sigRef).subscribe(setSv);
const um = getMetaStore(sigRef).subscribe(setMeta);
return () => { uv(); um(); };
}, [sigRef?.ds, sigRef?.name]);
const sourceMode = widget.options['sourceMode'] ?? 'bitset';
const bits = parseInt(widget.options['bits'] ?? '8', 10);
const labelsRaw = widget.options['labels'] ?? '';
const colorsTrueRaw = widget.options['colorsTrue'] ?? '';
@@ -32,8 +35,49 @@ export default function MultiLed({ widget, onContextMenu }: Props) {
const quality = sv.quality;
const isUncertain = quality === 'uncertain' || quality === 'unknown';
const rawV = sv.value;
// ── Array source mode ──────────────────────────────────────────────────────
// When sourceMode === 'array' (or the bound value is already an array), render
// one LED per element. LED count tracks the live array length.
// When meta.elem === 'bool', the on/off labels from labelArr are used as-is.
const isArrayMode = sourceMode === 'array' || Array.isArray(rawV);
if (isArrayMode) {
const arr: any[] = Array.isArray(rawV) ? rawV : [];
const isBoolElem = meta?.elem === 'bool';
return (
<div
class="multiled-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{arr.map((elem, i) => {
const on = isBoolElem ? Boolean(elem) : (Number(elem) !== 0 && elem !== false && elem !== null);
const trueColor = colorsTrueArr[i] ?? colorsTrueArr[0] ?? '#22c55e';
const falseColor = colorsFalseArr[i] ?? colorsFalseArr[0] ?? '#ef4444';
const color = on ? trueColor : falseColor;
// Label: per-element override, then generic index
const elemLabel = labelArr[i] ?? String(i);
return (
<div key={i} class="bit-item">
<div
class={`led-circle${isUncertain ? ' blink' : ''}`}
style={`background:${color};box-shadow:0 0 6px ${color}88;`}
/>
<div class="bit-label">{elemLabel}</div>
</div>
);
})}
{arr.length === 0 && (
<div class="bit-label" style="color:#6b7280;padding:4px"></div>
)}
</div>
);
}
// ── Bitset source mode (default) ───────────────────────────────────────────
const intValue = rawV === null || rawV === undefined ? 0
: typeof rawV === 'number' ? Math.floor(rawV) : parseInt(String(rawV), 10);
+25 -8
View File
@@ -406,7 +406,13 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } }
: undefined;
switch (plotType) {
// If any signal has delivered an array value (stored in waveforms[]),
// render all signals as waveform traces regardless of the configured plotType.
// This handles local array vars and EPICS waveform PVs transparently.
const hasWaveformData = waveforms.some(w => w.length > 0);
const effectivePlotType = hasWaveformData ? 'waveform' : plotType;
switch (effectivePlotType) {
case 'histogram': {
// Distribution over all buffered samples (ring-buffer bounded).
const { labels, counts } = buildHistogram(buffers.map(b => b.values));
@@ -664,14 +670,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
if (plotType === 'waveform') {
// Array-valued sample: keep only the latest waveform and redraw.
if (Array.isArray(sv.value)) {
waveforms[i] = sv.value.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
} else {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
waveforms[i] = isNaN(v) ? [] : [v];
// Array-valued signal (EPICS waveform or local array var): always route
// through the waveform sample path. Nested (2-D) arrays are skipped —
// they fall through to the "unsupported" ECharts placeholder.
if (Array.isArray(sv.value)) {
const flat = sv.value as any[];
// Skip 2-D (nested) arrays — not renderable as a simple waveform.
if (flat.length > 0 && Array.isArray(flat[0])) {
if (echart) echart.setOption(echartsOption(), { notMerge: true });
return;
}
waveforms[i] = flat.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
if (echart) echart.setOption(echartsOption(), { notMerge: true });
return;
}
if (plotType === 'waveform') {
// Scalar sample while in waveform mode: treat as a single-element waveform.
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
waveforms[i] = isNaN(v) ? [] : [v];
if (echart) echart.setOption(echartsOption(), { notMerge: true });
} else if (plotType === 'timeseries') {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
+91
View File
@@ -81,6 +81,80 @@ function TableRow({ sig, label, cols, fmt, unitOverride }: RowProps) {
);
}
interface ArrayBodyProps {
sig: SignalRef;
cols: Col[];
fmt: string;
unitOverride: string;
// For 2-D arrays: map column positions to inner-array indices.
// e.g. "0,2" means col[0] → elem[0], col[1] → elem[2]
colIndices?: number[];
}
// Array source mode body: subscribe to a single signal whose value is an array
// and render one row per element. For 2-D (elem:'array') arrays, each row is
// the outer index and configured colIndices map columns to inner positions.
// Returns an array of <tr> elements (no wrapper needed — inserted into <tbody>).
function ArrayTableBody({ sig, cols, fmt, unitOverride, colIndices }: ArrayBodyProps) {
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
const uv = getSignalStore(sig).subscribe(setSv);
const um = getMetaStore(sig).subscribe(setMeta);
return () => { uv(); um(); };
}, [sig.ds, sig.name]);
const unit = unitOverride === 'none' ? '' : (unitOverride || meta?.unit || '');
const time = sv.ts ? new Date(sv.ts).toLocaleTimeString() : '';
const quality = sv.quality;
const rawArr: any[] = Array.isArray(sv.value) ? sv.value : [];
const is2D = meta?.elem === 'array';
if (rawArr.length === 0) {
return <tr><td class="tw-empty" colSpan={cols.length}></td></tr>;
}
// Return an array of <tr> elements — JSX arrays are valid react-mode children.
return rawArr.map((elem, idx) => {
const cells = cols.map((c, ci) => {
switch (c) {
case 'name':
return <td key={c} class="tw-name">{idx}</td>;
case 'value': {
let display: string;
if (is2D && Array.isArray(elem)) {
// 2-D: show the inner element at the configured column index mapping.
const innerIdx = colIndices ? (colIndices[ci] ?? ci) : ci;
const inner = (elem as any[])[innerIdx];
display = inner === undefined ? '—'
: typeof inner === 'number' ? formatValue(inner, fmt) : String(inner);
} else if (typeof elem === 'number') {
display = formatValue(elem, fmt);
} else if (elem === null || elem === undefined) {
display = '—';
} else {
display = String(elem);
}
return <td key={c} class="tw-value">{display}</td>;
}
case 'unit':
return <td key={c} class="tw-unit">{unit}</td>;
case 'quality':
return (
<td key={c} class="tw-quality">
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
</td>
);
case 'time':
return <td key={c} class="tw-time">{time}</td>;
}
});
return <tr key={idx}>{cells}</tr>;
}) as any;
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// A tabular multi-signal readout: each bound signal is one row, with a
@@ -95,6 +169,14 @@ export default function TableWidget({ widget, onContextMenu }: Props) {
const fmt = o['format'] ?? '';
const unitOverride = o['unit'] ?? '';
const labels = (o['labels'] ?? '').split(',');
// sourceMode:'array' — use first bound signal as an array; each element = one row.
const sourceMode = o['sourceMode'] ?? '';
const isArrayMode = sourceMode === 'array';
// colIndices: for 2-D arrays, map column positions to inner-array positions.
// Stored as a comma-separated list of integers, e.g. "0,2,4".
const colIndices = o['colIndices']
? o['colIndices'].split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n))
: undefined;
return (
<div
@@ -113,6 +195,15 @@ export default function TableWidget({ widget, onContextMenu }: Props) {
<tbody>
{widget.signals.length === 0 ? (
<tr><td class="tw-empty" colSpan={cols.length}>No signals drop signals here</td></tr>
) : isArrayMode && widget.signals[0] ? (
// Array source mode: first signal's array value → one row per element.
<ArrayTableBody
sig={widget.signals[0]}
cols={cols}
fmt={fmt}
unitOverride={unitOverride}
colIndices={colIndices}
/>
) : (
widget.signals.map((s, i) => (
<TableRow