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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user