diff --git a/web/src/widgets/MultiLed.tsx b/web/src/widgets/MultiLed.tsx index 2a858ce..1369d41 100644 --- a/web/src/widgets/MultiLed.tsx +++ b/web/src/widgets/MultiLed.tsx @@ -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(DEFAULT_VALUE); + const [meta, setMeta] = useState(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 ( +
+ {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 ( +
+
+
{elemLabel}
+
+ ); + })} + {arr.length === 0 && ( +
+ )} +
+ ); + } + + // ── Bitset source mode (default) ─────────────────────────────────────────── const intValue = rawV === null || rawV === undefined ? 0 : typeof rawV === 'number' ? Math.floor(rawV) : parseInt(String(rawV), 10); diff --git a/web/src/widgets/PlotWidget.tsx b/web/src/widgets/PlotWidget.tsx index 778ac04..454f42a 100644 --- a/web/src/widgets/PlotWidget.tsx +++ b/web/src/widgets/PlotWidget.tsx @@ -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)); diff --git a/web/src/widgets/TableWidget.tsx b/web/src/widgets/TableWidget.tsx index 24bfd7a..c9663a7 100644 --- a/web/src/widgets/TableWidget.tsx +++ b/web/src/widgets/TableWidget.tsx @@ -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 elements (no wrapper needed — inserted into ). +function ArrayTableBody({ sig, cols, fmt, unitOverride, colIndices }: ArrayBodyProps) { + const [sv, setSv] = useState(DEFAULT_VALUE); + const [meta, setMeta] = useState(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 —; + } + + // Return an array of elements — JSX arrays are valid react-mode children. + return rawArr.map((elem, idx) => { + const cells = cols.map((c, ci) => { + switch (c) { + case 'name': + return {idx}; + 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 {display}; + } + case 'unit': + return {unit}; + case 'quality': + return ( + + + + ); + case 'time': + return {time}; + } + }); + return {cells}; + }) 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 (
{widget.signals.length === 0 ? ( No signals — drop signals here + ) : isArrayMode && widget.signals[0] ? ( + // Array source mode: first signal's array value → one row per element. + ) : ( widget.signals.map((s, i) => (