working epics ioc and tested
This commit is contained in:
+12
-5
@@ -15,6 +15,8 @@ export default function App() {
|
||||
const [mode, setMode] = useState<AppMode>('view');
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
||||
// Interface to show when returning to view mode after editing
|
||||
const [viewTarget, setViewTarget] = useState<Interface | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||
@@ -36,7 +38,8 @@ export default function App() {
|
||||
setMode('edit');
|
||||
}
|
||||
|
||||
function exitEdit() {
|
||||
function exitEdit(iface: Interface) {
|
||||
setViewTarget(iface);
|
||||
setMode('view');
|
||||
setEditTarget(null);
|
||||
}
|
||||
@@ -46,10 +49,14 @@ export default function App() {
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view'
|
||||
? <ViewMode onEdit={enterEdit} />
|
||||
: <EditMode initial={editTarget} onDone={exitEdit} />
|
||||
}
|
||||
{/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed.
|
||||
EditMode is conditionally rendered; it resets from props each time anyway. */}
|
||||
<div style={`display:${mode === 'view' ? 'contents' : 'none'}`}>
|
||||
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} />
|
||||
</div>
|
||||
{mode === 'edit' && (
|
||||
<EditMode initial={editTarget} onDone={exitEdit} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+24
-2
@@ -14,6 +14,7 @@ import PlotWidget from './widgets/PlotWidget';
|
||||
import ImageWidget from './widgets/ImageWidget';
|
||||
import LinkWidget from './widgets/LinkWidget';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import InfoPanel from './InfoPanel';
|
||||
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
textview: TextView,
|
||||
@@ -41,12 +42,14 @@ interface Props {
|
||||
iface: Interface | null;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
timeRange?: { start: string; end: string } | null;
|
||||
onPlot?: (signal: SignalRef) => void;
|
||||
}
|
||||
|
||||
export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) {
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxState>({
|
||||
visible: false, x: 0, y: 0, signal: null,
|
||||
});
|
||||
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
|
||||
|
||||
function onCtxMenu(e: MouseEvent, widget: Widget) {
|
||||
e.preventDefault();
|
||||
@@ -54,6 +57,18 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null });
|
||||
}
|
||||
|
||||
function closeCtxMenu() {
|
||||
setCtxMenu(c => ({ ...c, visible: false }));
|
||||
}
|
||||
|
||||
function handleInfo() {
|
||||
if (ctxMenu.signal) setInfoSignal(ctxMenu.signal);
|
||||
}
|
||||
|
||||
function handlePlot() {
|
||||
if (ctxMenu.signal && onPlot) onPlot(ctxMenu.signal);
|
||||
}
|
||||
|
||||
if (!iface) {
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
@@ -90,10 +105,17 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
{...ctxMenu}
|
||||
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
|
||||
onClose={closeCtxMenu}
|
||||
onInfo={handleInfo}
|
||||
onPlot={onPlot ? handlePlot : undefined}
|
||||
/>
|
||||
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-4
@@ -10,9 +10,11 @@ interface Props {
|
||||
y: number;
|
||||
signal: SignalRef | null;
|
||||
onClose: () => void;
|
||||
onInfo?: () => void;
|
||||
onPlot?: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, onPlot }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -36,12 +38,20 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
if (!visible) return null;
|
||||
|
||||
function copySignalName() {
|
||||
if (signal) {
|
||||
navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||
}
|
||||
if (signal) navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||
onClose();
|
||||
}
|
||||
|
||||
function openInfo() {
|
||||
onClose();
|
||||
onInfo?.();
|
||||
}
|
||||
|
||||
function openPlot() {
|
||||
onClose();
|
||||
onPlot?.();
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// Phase 5+: export CSV data
|
||||
onClose();
|
||||
@@ -62,6 +72,9 @@ export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={openInfo}>Signal info</button>
|
||||
<button class="ctx-item" onClick={openPlot}>Plot</button>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
|
||||
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import HelpModal from './HelpModal';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: () => void;
|
||||
onDone: (iface: Interface) => void;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
@@ -229,7 +229,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
function handleLeave() {
|
||||
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||
onDone();
|
||||
onDone(iface);
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { getMetaStore, getSignalStore } from './lib/stores';
|
||||
import type { SignalRef, SignalMeta, SignalValue } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
signal: SignalRef;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(v: any): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
if (Array.isArray(v)) return `[${v.slice(0, 8).map((x: any) => fmt(x)).join(', ')}${v.length > 8 ? ', …' : ''}]`;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function fmtTs(ts: string | null): string {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleString(); } catch { return ts; }
|
||||
}
|
||||
|
||||
export default function InfoPanel({ signal, onClose }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const [sv, setSv] = useState<SignalValue>({ value: null, quality: 'unknown', ts: null });
|
||||
|
||||
useEffect(() => {
|
||||
const unsubM = getMetaStore(signal).subscribe(setMeta);
|
||||
const unsubV = getSignalStore(signal).subscribe(setSv);
|
||||
return () => { unsubM(); unsubV(); };
|
||||
}, [signal.ds, signal.name]);
|
||||
|
||||
return (
|
||||
<div class="info-panel">
|
||||
<div class="info-panel-header">
|
||||
<span class="info-panel-title">Signal Info</span>
|
||||
<button class="info-panel-close" onClick={onClose} title="Close">✕</button>
|
||||
</div>
|
||||
<div class="info-panel-body">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Datasource</td><td class="info-val info-mono">{signal.ds}</td></tr>
|
||||
<tr><td class="info-key">Name</td><td class="info-val info-mono">{signal.name}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="info-sep" />
|
||||
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="info-key">Value</td>
|
||||
<td class="info-val">
|
||||
<span
|
||||
class="quality-dot"
|
||||
style={`background:${qualityColor(sv.quality)};display:inline-block;margin-right:5px;vertical-align:middle;`}
|
||||
/>
|
||||
{fmt(sv.value)}{meta?.unit ? ` ${meta.unit}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td class="info-key">Quality</td><td class="info-val">{sv.quality}</td></tr>
|
||||
<tr><td class="info-key">Timestamp</td><td class="info-val">{fmtTs(sv.ts)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr><td class="info-key">Type</td><td class="info-val">{meta.type}</td></tr>
|
||||
{meta.unit && <tr><td class="info-key">Unit</td><td class="info-val">{meta.unit}</td></tr>}
|
||||
<tr><td class="info-key">Writable</td><td class="info-val">{meta.writable ? 'Yes' : 'No'}</td></tr>
|
||||
{(meta.displayLow !== 0 || meta.displayHigh !== 0) && (
|
||||
<tr>
|
||||
<td class="info-key">Display range</td>
|
||||
<td class="info-val">{meta.displayLow} … {meta.displayHigh}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meta.enumStrings && meta.enumStrings.length > 0 && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">States</div>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
{meta.enumStrings.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td class="info-key">{i}</td>
|
||||
<td class="info-val info-mono">{s}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.description && (
|
||||
<div>
|
||||
<div class="info-sep" />
|
||||
<div class="info-section-hdr">Description</div>
|
||||
<div class="info-desc">{meta.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import { getSignalStore, getMetaStore } from './lib/stores';
|
||||
import type { SignalRef, SignalMeta } from './lib/types';
|
||||
|
||||
export interface PlotSignal {
|
||||
ref: SignalRef;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
signals: PlotSignal[];
|
||||
onRemove: (ref: SignalRef) => void;
|
||||
}
|
||||
|
||||
interface Buf { ts: number[]; vals: number[]; }
|
||||
|
||||
// Large enough to hold a full 1-hour window at ~10 Hz per signal.
|
||||
const RING_MAX = 50_000;
|
||||
|
||||
const TIME_WINDOWS = [
|
||||
{ label: '10s', secs: 10 },
|
||||
{ label: '30s', secs: 30 },
|
||||
{ label: '1m', secs: 60 },
|
||||
{ label: '5m', secs: 300 },
|
||||
{ label: '15m', secs: 900 },
|
||||
{ label: '1h', secs: 3600 },
|
||||
];
|
||||
|
||||
interface Stat { min: number; max: number; mean: number; last: number; }
|
||||
|
||||
function pushSample(buf: Buf, t: number, v: number) {
|
||||
buf.ts.push(t);
|
||||
buf.vals.push(v);
|
||||
if (buf.ts.length > RING_MAX) {
|
||||
buf.ts.splice(0, buf.ts.length - RING_MAX);
|
||||
buf.vals.splice(0, buf.vals.length - RING_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function buildData(signals: PlotSignal[], bufs: Map<string, Buf>, windowSec: number): uPlot.AlignedData {
|
||||
const cutoff = Date.now() / 1000 - windowSec;
|
||||
const allTs = new Set<number>();
|
||||
for (const s of signals) {
|
||||
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
|
||||
if (buf) for (const t of buf.ts) if (t >= cutoff) allTs.add(t);
|
||||
}
|
||||
const sorted = Array.from(allTs).sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [[], ...signals.map(() => [])] as uPlot.AlignedData;
|
||||
return [
|
||||
sorted,
|
||||
...signals.map(s => {
|
||||
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
|
||||
if (!buf) return sorted.map(() => null);
|
||||
const m = new Map<number, number>();
|
||||
buf.ts.forEach((t, i) => { if (t >= cutoff) m.set(t, buf.vals[i]); });
|
||||
return sorted.map(t => m.get(t) ?? null);
|
||||
}),
|
||||
] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
function computeStats(buf: Buf | undefined, windowSec: number): Stat | null {
|
||||
if (!buf || buf.ts.length === 0) return null;
|
||||
const cutoff = Date.now() / 1000 - windowSec;
|
||||
let min = Infinity, max = -Infinity, sum = 0, n = 0, last = NaN;
|
||||
buf.ts.forEach((t, i) => {
|
||||
if (t >= cutoff) { const v = buf.vals[i]; if (v < min) min = v; if (v > max) max = v; sum += v; n++; last = v; }
|
||||
});
|
||||
return n > 0 ? { min, max, mean: sum / n, last } : null;
|
||||
}
|
||||
|
||||
function fmtN(v: number | undefined | null): string {
|
||||
if (v === undefined || v === null || !isFinite(v)) return '—';
|
||||
const a = Math.abs(v);
|
||||
if (a === 0) return '0';
|
||||
if (a >= 10000 || (a < 0.001 && a > 0)) return v.toExponential(3);
|
||||
return v.toPrecision(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export default function PlotPanel({ signals, onRemove }: Props) {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const [windowSec, setWindowSec] = useState(60);
|
||||
const [stats, setStats] = useState<Array<Stat | null>>([]);
|
||||
const [metas, setMetas] = useState<Map<string, SignalMeta>>(new Map());
|
||||
|
||||
// Persistent ring buffers survive signal list changes (so we don't lose history)
|
||||
const bufsRef = useRef<Map<string, Buf>>(new Map());
|
||||
|
||||
const signalsKey = signals.map(s => `${s.ref.ds}:${s.ref.name}:${s.color}`).join(',');
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const el = chartRef.current;
|
||||
|
||||
// Ensure buffers exist for all current signals
|
||||
for (const s of signals) {
|
||||
const key = `${s.ref.ds}\0${s.ref.name}`;
|
||||
if (!bufsRef.current.has(key)) bufsRef.current.set(key, { ts: [], vals: [] });
|
||||
}
|
||||
|
||||
if (signals.length === 0) return;
|
||||
|
||||
// Must be defined BEFORE new uPlot() because the range function closes over it
|
||||
// and uPlot calls range() synchronously during construction.
|
||||
let currentWindow = windowSec;
|
||||
|
||||
// Create uPlot
|
||||
let uplot = new uPlot(
|
||||
{
|
||||
width: Math.max(el.clientWidth, 200),
|
||||
height: Math.max(el.clientHeight, 100),
|
||||
legend: { show: false },
|
||||
cursor: { show: true },
|
||||
axes: [
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
{
|
||||
stroke: '#64748b',
|
||||
grid: { stroke: '#2d3748' },
|
||||
ticks: { stroke: '#475569' },
|
||||
values: (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => (v === null || v === undefined) ? '' : fmtN(v)),
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
range: (_u: uPlot, _min: number, _max: number): [number, number] => {
|
||||
const now = Date.now() / 1000;
|
||||
return [now - currentWindow, now];
|
||||
},
|
||||
},
|
||||
y: { auto: true },
|
||||
},
|
||||
series: [
|
||||
{},
|
||||
...signals.map(s => ({
|
||||
label: s.ref.name,
|
||||
stroke: s.color,
|
||||
width: 1.5,
|
||||
spanGaps: false,
|
||||
})),
|
||||
],
|
||||
},
|
||||
buildData(signals, bufsRef.current, windowSec),
|
||||
el,
|
||||
);
|
||||
|
||||
// Subscriptions to signal stores
|
||||
const unsubs: Array<() => void> = [];
|
||||
let dirty = false;
|
||||
|
||||
signals.forEach(s => {
|
||||
const key = `${s.ref.ds}\0${s.ref.name}`;
|
||||
|
||||
const unsubV = getSignalStore(s.ref).subscribe(sv => {
|
||||
if (sv.value === null || sv.ts === null) return;
|
||||
const v = typeof sv.value === 'number' ? sv.value :
|
||||
Array.isArray(sv.value) ? sv.value[0] :
|
||||
parseFloat(String(sv.value));
|
||||
if (!isFinite(v)) return;
|
||||
const buf = bufsRef.current.get(key);
|
||||
if (buf) { pushSample(buf, new Date(sv.ts).getTime() / 1000, v); dirty = true; }
|
||||
});
|
||||
unsubs.push(unsubV);
|
||||
|
||||
const unsubM = getMetaStore(s.ref).subscribe(m => {
|
||||
if (m) setMetas(prev => new Map(prev).set(key, m));
|
||||
});
|
||||
unsubs.push(unsubM);
|
||||
});
|
||||
|
||||
// Expose a setter for the window so the windowSec useEffect can poke it
|
||||
(el as any)._setWindow = (secs: number) => { currentWindow = secs; dirty = true; };
|
||||
|
||||
// RAF update loop — redraws on new data, every ~1 s to advance the time axis,
|
||||
// or immediately when the chart becomes visible again after being hidden.
|
||||
// Skips updates while the chart is hidden (display:none → 0×0) to prevent
|
||||
// uPlot internal state corruption.
|
||||
let lastDrawTime = 0;
|
||||
let wasVisible = false;
|
||||
let rafId = requestAnimationFrame(function tick(ts) {
|
||||
const isVisible = el.clientWidth > 0 && el.clientHeight > 0;
|
||||
const becameVisible = isVisible && !wasVisible;
|
||||
wasVisible = isVisible;
|
||||
if (isVisible && (dirty || becameVisible || ts - lastDrawTime >= 1000)) {
|
||||
dirty = false;
|
||||
lastDrawTime = ts;
|
||||
uplot.setData(buildData(signals, bufsRef.current, currentWindow));
|
||||
setStats(signals.map(s =>
|
||||
computeStats(bufsRef.current.get(`${s.ref.ds}\0${s.ref.name}`), currentWindow)
|
||||
));
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
// Resize observer
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = el.clientWidth, h = el.clientHeight;
|
||||
if (w > 0 && h > 0) uplot.setSize({ width: w, height: h });
|
||||
});
|
||||
ro.observe(el);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
unsubs.forEach(u => u());
|
||||
uplot.destroy();
|
||||
delete (el as any)._setWindow;
|
||||
};
|
||||
}, [signalsKey]);
|
||||
|
||||
// When the window selector changes, poke the running chart
|
||||
useEffect(() => {
|
||||
if (chartRef.current) (chartRef.current as any)._setWindow?.(windowSec);
|
||||
}, [windowSec]);
|
||||
|
||||
return (
|
||||
<div class="plot-panel">
|
||||
<div class="plot-panel-toolbar">
|
||||
<span class="plot-panel-title">Live Plot</span>
|
||||
<div class="plot-window-btns">
|
||||
{TIME_WINDOWS.map(tw => (
|
||||
<button
|
||||
key={tw.secs}
|
||||
class={`toolbar-btn${windowSec === tw.secs ? ' toolbar-btn-active' : ''}`}
|
||||
onClick={() => setWindowSec(tw.secs)}
|
||||
>
|
||||
{tw.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plot-panel-body">
|
||||
{signals.length === 0 ? (
|
||||
<div class="plot-empty-hint">
|
||||
Right-click any widget in the <b>HMI</b> view and choose <b>Plot</b> to add signals here.
|
||||
</div>
|
||||
) : (
|
||||
<div class="plot-panel-content">
|
||||
<div class="plot-panel-legend">
|
||||
{signals.map((s, i) => {
|
||||
const key = `${s.ref.ds}\0${s.ref.name}`;
|
||||
const st = stats[i] ?? null;
|
||||
const meta = metas.get(key);
|
||||
const unit = meta?.unit ?? '';
|
||||
return (
|
||||
<div class="plot-legend-item" key={key}>
|
||||
<div class="plot-legend-hdr">
|
||||
<span class="plot-legend-swatch" style={`background:${s.color};`} />
|
||||
<span class="plot-legend-name" title={`${s.ref.ds} / ${s.ref.name}`}>
|
||||
{s.ref.name}
|
||||
</span>
|
||||
<button class="plot-legend-rm" onClick={() => onRemove(s.ref)} title="Remove">✕</button>
|
||||
</div>
|
||||
<table class="plot-stats-tbl">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Last</td>
|
||||
<td>{st ? `${fmtN(st.last)}${unit ? ' ' + unit : ''}` : '—'}</td>
|
||||
</tr>
|
||||
<tr><td>Min</td><td>{st ? fmtN(st.min) : '—'}</td></tr>
|
||||
<tr><td>Max</td><td>{st ? fmtN(st.max) : '—'}</td></tr>
|
||||
<tr><td>Mean</td><td>{st ? fmtN(st.mean) : '—'}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div class="plot-panel-chart" ref={chartRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-12
@@ -37,6 +37,8 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
|
||||
|
||||
// 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']);
|
||||
// Widgets with multiple signals
|
||||
@@ -154,24 +156,34 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
signal widgets use it as header label */}
|
||||
{w.type !== 'image' && w.type !== 'link' && (
|
||||
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
||||
<TextInput
|
||||
value={w.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
{LABEL_WIDGETS.has(w.type) && (
|
||||
<span class="prop-hint">Empty = signal name</span>
|
||||
{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 override">
|
||||
<TextInput
|
||||
value={w.options['unit'] ?? ''}
|
||||
onCommit={(v) => setOpt('unit', v)}
|
||||
/>
|
||||
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -271,6 +283,12 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<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)}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticEditor from './SyntheticEditor';
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
@@ -47,6 +48,7 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
const [cfAvailable, setCfAvailable] = useState(false);
|
||||
const [cfSearching, setCfSearching] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
||||
const csvRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function loadGroups() {
|
||||
@@ -284,6 +286,14 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
{group.ds === 'synthetic' && (
|
||||
<button
|
||||
class="icon-btn signal-edit-btn"
|
||||
title="Edit pipeline"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); setEditSynthetic(sig.name); }}
|
||||
>✎</button>
|
||||
)}
|
||||
{isCustom && (
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
@@ -330,6 +340,14 @@ export default function SignalTree({ onDragStart }: Props) {
|
||||
onCreated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editSynthetic && (
|
||||
<SyntheticEditor
|
||||
name={editSynthetic}
|
||||
onClose={() => setEditSynthetic(null)}
|
||||
onSaved={loadGroups}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text';
|
||||
default: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] },
|
||||
];
|
||||
|
||||
interface PipelineNode {
|
||||
type: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SignalDef {
|
||||
name: string;
|
||||
ds: string;
|
||||
signal: string;
|
||||
pipeline: PipelineNode[];
|
||||
meta: {
|
||||
unit?: string;
|
||||
description?: string;
|
||||
displayLow?: number;
|
||||
displayHigh?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
||||
const [def, setDef] = useState<SignalDef | null>(null);
|
||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||
const [addType, setAddType] = useState('gain');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
||||
.then((d: SignalDef) => {
|
||||
setDef(d);
|
||||
setPipeline(d.pipeline ?? []);
|
||||
})
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
function nodeLabel(type: string): string {
|
||||
return NODE_TYPES.find(n => n.type === type)?.label ?? type;
|
||||
}
|
||||
|
||||
function nodeParamDefs(type: string): NodeParam[] {
|
||||
return NODE_TYPES.find(n => n.type === type)?.params ?? [];
|
||||
}
|
||||
|
||||
function setNodeParam(idx: number, key: string, val: string) {
|
||||
setPipeline(p => p.map((node, i) => {
|
||||
if (i !== idx) return node;
|
||||
const paramDef = nodeParamDefs(node.type).find(pd => pd.key === key);
|
||||
const typed: any = paramDef?.type === 'number' ? parseFloat(val) : val;
|
||||
return { ...node, params: { ...node.params, [key]: typed } };
|
||||
}));
|
||||
}
|
||||
|
||||
function removeNode(idx: number) {
|
||||
setPipeline(p => p.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function moveNode(idx: number, dir: -1 | 1) {
|
||||
setPipeline(p => {
|
||||
const next = [...p];
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= next.length) return next;
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
const nodeDef = NODE_TYPES.find(n => n.type === addType)!;
|
||||
const params: Record<string, any> = {};
|
||||
for (const p of nodeDef.params) {
|
||||
params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
||||
}
|
||||
setPipeline(p => [...p, { type: addType, params }]);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!def) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = { ...def, pipeline };
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={(e) => e.stopPropagation()} style="max-width:560px;">
|
||||
<div class="wizard-header">
|
||||
<span>Edit Pipeline — {name}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{error && <p class="wizard-error">{error}</p>}
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : (
|
||||
<div>
|
||||
<div class="wizard-section-title">Pipeline nodes</div>
|
||||
{pipeline.length === 0 && (
|
||||
<p class="hint" style="padding:0.5rem 0;">No processing nodes — input passes through unchanged.</p>
|
||||
)}
|
||||
{pipeline.map((node, idx) => {
|
||||
const paramDefs = nodeParamDefs(node.type);
|
||||
return (
|
||||
<div key={idx} class="synth-pipeline-node">
|
||||
<div class="synth-node-header">
|
||||
<span class="synth-node-index">{idx + 1}</span>
|
||||
<span class="synth-node-label">{nodeLabel(node.type)}</span>
|
||||
<div class="synth-node-actions">
|
||||
<button class="icon-btn" title="Move up" disabled={idx === 0} onClick={() => moveNode(idx, -1)}>↑</button>
|
||||
<button class="icon-btn" title="Move down" disabled={idx === pipeline.length - 1} onClick={() => moveNode(idx, 1)}>↓</button>
|
||||
<button class="icon-btn" title="Remove node" onClick={() => removeNode(idx)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{paramDefs.map(pd => (
|
||||
<div key={pd.key} class="wizard-field wizard-field-inline">
|
||||
<label>{pd.label}</label>
|
||||
<input
|
||||
class="prop-input"
|
||||
type={pd.type === 'number' ? 'number' : 'text'}
|
||||
value={node.params[pd.key] ?? pd.default}
|
||||
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="synth-add-node-row">
|
||||
<select class="prop-select" value={addType} onChange={(e) => setAddType((e.target as HTMLSelectElement).value)}>
|
||||
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
|
||||
</select>
|
||||
<button class="toolbar-btn" onClick={addNode}>+ Add node</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? 'Saving…' : 'Save Pipeline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,16 +14,14 @@ interface NodeParam {
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'factor', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_avg', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Lowpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] },
|
||||
{ type: 'highpass', label: 'Highpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] },
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integral', label: 'Integral', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'formula', label: 'Formula', params: [{ label: 'Expression (use "x")', key: 'expr', type: 'text', default: 'x * 1.0' }] },
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] },
|
||||
];
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
|
||||
+63
-4
@@ -2,14 +2,17 @@ import { h } from 'preact';
|
||||
import { useState, useMemo, useEffect } from 'preact/hooks';
|
||||
import InterfaceList from './InterfaceList';
|
||||
import Canvas from './Canvas';
|
||||
import PlotPanel from './PlotPanel';
|
||||
import type { PlotSignal } from './PlotPanel';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import type { Interface } from './lib/types';
|
||||
import type { Interface, SignalRef } from './lib/types';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
initialInterface?: Interface | null;
|
||||
}
|
||||
|
||||
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
|
||||
@@ -18,13 +21,19 @@ function toLocalInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default function ViewMode({ onEdit }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null);
|
||||
const PLOT_COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||
|
||||
type ViewTab = 'hmi' | 'plot';
|
||||
|
||||
export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
const [viewTab, setViewTab] = useState<ViewTab>('hmi');
|
||||
const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]);
|
||||
|
||||
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
||||
|
||||
@@ -39,6 +48,11 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// When returning from edit mode, show the edited interface
|
||||
useEffect(() => {
|
||||
if (initialInterface) setCurrentInterface(initialInterface);
|
||||
}, [initialInterface]);
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
setParseError(null);
|
||||
@@ -81,6 +95,20 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
setTimeRange(null);
|
||||
}
|
||||
|
||||
function addToPlot(ref: SignalRef) {
|
||||
const key = `${ref.ds}\0${ref.name}`;
|
||||
const already = plotSignals.some(s => `${s.ref.ds}\0${s.ref.name}` === key);
|
||||
if (!already) {
|
||||
const color = PLOT_COLORS[plotSignals.length % PLOT_COLORS.length];
|
||||
setPlotSignals(prev => [...prev, { ref, color }]);
|
||||
}
|
||||
setViewTab('plot');
|
||||
}
|
||||
|
||||
function removeFromPlot(ref: SignalRef) {
|
||||
setPlotSignals(prev => prev.filter(s => !(s.ref.ds === ref.ds && s.ref.name === ref.name)));
|
||||
}
|
||||
|
||||
const isLive = timeRange === null;
|
||||
|
||||
return (
|
||||
@@ -172,12 +200,43 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
handleLoad(await res.text());
|
||||
setViewTab('hmi');
|
||||
} catch (err) {
|
||||
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Canvas iface={currentInterface} onNavigate={handleNavigate} timeRange={timeRange} />
|
||||
|
||||
<div class="view-content-area">
|
||||
{/* Tab bar */}
|
||||
<div class="view-tabs">
|
||||
<button
|
||||
class={`view-tab${viewTab === 'hmi' ? ' view-tab-active' : ''}`}
|
||||
onClick={() => setViewTab('hmi')}
|
||||
>
|
||||
HMI
|
||||
</button>
|
||||
<button
|
||||
class={`view-tab${viewTab === 'plot' ? ' view-tab-active' : ''}`}
|
||||
onClick={() => setViewTab('plot')}
|
||||
>
|
||||
Plot{plotSignals.length > 0 ? ` (${plotSignals.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keep both panels mounted so ring-buffer data is never lost on tab switch */}
|
||||
<div style={`display:${viewTab === 'hmi' ? 'contents' : 'none'}`}>
|
||||
<Canvas
|
||||
iface={currentInterface}
|
||||
onNavigate={handleNavigate}
|
||||
timeRange={timeRange}
|
||||
onPlot={addToPlot}
|
||||
/>
|
||||
</div>
|
||||
<div style={`display:${viewTab === 'plot' ? 'contents' : 'none'}`}>
|
||||
<PlotPanel signals={plotSignals} onRemove={removeFromPlot} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* formatValue formats a numeric value according to a format string.
|
||||
*
|
||||
* Format string grammar: [width.][precision]type
|
||||
* type f — fixed-point: toFixed(precision)
|
||||
* e — exponential: toExponential(precision)
|
||||
* g — significant: toPrecision(precision), trailing zeros stripped
|
||||
*
|
||||
* Examples: "3f", "0.3f", "2e", "0.1e", "4g"
|
||||
* The optional leading "0." is ignored — only the digits before the type
|
||||
* letter are used as precision.
|
||||
*
|
||||
* An empty or unrecognised format falls back to the default display
|
||||
* (4 significant figures, trailing zeros stripped, exponential for very
|
||||
* large or very small values).
|
||||
*/
|
||||
export function formatValue(v: number | null | undefined, fmt: string): string {
|
||||
if (v === null || v === undefined || typeof v !== 'number') return '---';
|
||||
if (!Number.isFinite(v)) return String(v);
|
||||
|
||||
const s = fmt.trim();
|
||||
if (s) {
|
||||
// Match optional "0." prefix then digits then type letter
|
||||
const m = s.match(/^(?:\d+\.)?(\d+)([fFeEgG])$/);
|
||||
if (m) {
|
||||
const prec = Math.max(0, parseInt(m[1], 10));
|
||||
const type = m[2].toLowerCase();
|
||||
try {
|
||||
switch (type) {
|
||||
case 'f': return v.toFixed(prec);
|
||||
case 'e': return v.toExponential(prec);
|
||||
case 'g': return v.toPrecision(Math.max(1, prec)).replace(/\.?0+$/, '');
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: 4 significant figures, exponential for extreme magnitudes
|
||||
const a = Math.abs(v);
|
||||
if (a !== 0 && (a >= 1e5 || a < 1e-3)) return v.toExponential(3);
|
||||
return v.toPrecision(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
@@ -1372,6 +1372,78 @@ body {
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Synthetic Editor ────────────────────────────────────────────────────── */
|
||||
|
||||
.synth-pipeline-node {
|
||||
background: #1a2234;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.synth-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.synth-node-index {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
background: #2d3748;
|
||||
border-radius: 3px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
min-width: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.synth-node-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.synth-node-actions {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.synth-add-node-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.6rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid #2d3748;
|
||||
}
|
||||
.synth-add-node-row .prop-select { flex: 1; }
|
||||
|
||||
.wizard-field-inline {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
.wizard-field-inline label {
|
||||
min-width: 7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wizard-field-inline .prop-input { flex: 1; }
|
||||
|
||||
/* Edit button on synthetic signal items */
|
||||
.signal-edit-btn {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.signal-item:hover .signal-edit-btn { opacity: 1; }
|
||||
/* When edit button is present, remove-btn should not auto-margin */
|
||||
.signal-edit-btn + .signal-remove-btn { margin-left: 0; }
|
||||
|
||||
/* ── Properties Pane ─────────────────────────────────────────────────────── */
|
||||
|
||||
.props-panel {
|
||||
@@ -1433,6 +1505,15 @@ body {
|
||||
|
||||
.prop-input-num { width: 70px; flex: none; }
|
||||
|
||||
/* Column wrapper: input on top, hint below — avoids squeezing input */
|
||||
.prop-field-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prop-select {
|
||||
flex: 1;
|
||||
background: #0f1117;
|
||||
@@ -2073,3 +2154,286 @@ kbd {
|
||||
margin-top: 0.15rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── View tabs ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.view-content-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 0.5rem;
|
||||
height: 36px;
|
||||
background: #0f1117;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-tab:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.view-tab-active {
|
||||
color: #e2e8f0;
|
||||
border-bottom-color: #4a9eff;
|
||||
}
|
||||
|
||||
/* ── InfoPanel ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.info-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
z-index: 8000;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
min-width: 260px;
|
||||
max-width: 340px;
|
||||
max-height: calc(100vh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.info-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-panel-title {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.info-panel-close:hover { background: #2d3748; color: #e2e8f0; }
|
||||
|
||||
.info-panel-body {
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-key {
|
||||
padding: 0.2rem 0.75rem;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
width: 36%;
|
||||
}
|
||||
|
||||
.info-val {
|
||||
padding: 0.2rem 0.75rem 0.2rem 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.info-sep {
|
||||
height: 1px;
|
||||
background: #1e293b;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.info-section-hdr {
|
||||
padding: 0.2rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
padding: 0.25rem 0.75rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── PlotPanel ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.plot-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #0f1117;
|
||||
}
|
||||
|
||||
.plot-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 40px;
|
||||
background: #0f1117;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plot-panel-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plot-window-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.plot-panel-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plot-empty-hint {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #475569;
|
||||
font-size: 0.88rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.plot-panel-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.plot-panel-legend {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #1e293b;
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.plot-legend-item {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.plot-legend-item:hover { background: #1a1f2e; }
|
||||
|
||||
.plot-legend-hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.plot-legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plot-legend-name {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #cbd5e1;
|
||||
font-family: ui-monospace, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plot-legend-rm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.15rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.plot-legend-rm:hover { opacity: 1; color: #f87171; }
|
||||
|
||||
.plot-stats-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.plot-stats-tbl td {
|
||||
padding: 0.05rem 0;
|
||||
color: #475569;
|
||||
}
|
||||
.plot-stats-tbl td:first-child {
|
||||
width: 44px;
|
||||
color: #334155;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.plot-stats-tbl td:last-child {
|
||||
color: #94a3b8;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.plot-panel-chart {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* uPlot inside plot-panel needs full dimensions */
|
||||
.plot-panel-chart .uplot,
|
||||
.plot-panel-chart .u-wrap {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -34,6 +35,7 @@ export default function BarH({ widget, onContextMenu }: Props) {
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
@@ -41,7 +43,7 @@ export default function BarH({ widget, onContextMenu }: Props) {
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
return formatValue(rawValue, fmt);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -34,6 +35,7 @@ export default function BarV({ widget, onContextMenu }: Props) {
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
@@ -41,7 +43,7 @@ export default function BarV({ widget, onContextMenu }: Props) {
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
return formatValue(rawValue, fmt);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -60,13 +61,14 @@ export default function Gauge({ widget, onContextMenu }: Props) {
|
||||
const thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null;
|
||||
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
return formatValue(rawValue, fmt);
|
||||
}
|
||||
|
||||
function fillColor(): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import uPlot from 'uplot';
|
||||
import * as echarts from 'echarts';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalRef } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -82,6 +83,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
||||
const histValues: number[][] = signals.map(() => []);
|
||||
const unsubs: (() => void)[] = [];
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
||||
let uplot: uPlot | null = null;
|
||||
@@ -110,6 +112,37 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
function makeUplotOpts(): uPlot.Options {
|
||||
const yAxis: uPlot.Axis = {
|
||||
stroke: '#64748b',
|
||||
grid: { stroke: '#2d3748' },
|
||||
ticks: { stroke: '#475569' },
|
||||
};
|
||||
if (fmt) {
|
||||
yAxis.values = (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => (v === null || v === undefined) ? '' : formatValue(v, fmt));
|
||||
}
|
||||
const seriesConf: uPlot.Series[] = [
|
||||
{},
|
||||
...signals.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: s.color ?? COLORS[i % COLORS.length],
|
||||
width: 1.5,
|
||||
})),
|
||||
];
|
||||
return {
|
||||
width: w,
|
||||
height: h,
|
||||
series: seriesConf,
|
||||
legend: { show: showLegend },
|
||||
axes: [
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
yAxis,
|
||||
],
|
||||
scales: { x: { time: true }, y: scaleY },
|
||||
};
|
||||
}
|
||||
|
||||
// ── ECharts ────────────────────────────────────────────────────────────
|
||||
let echart: echarts.EChartsType | null = null;
|
||||
|
||||
@@ -219,37 +252,15 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
|
||||
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
|
||||
|
||||
if (plotType === 'timeseries') {
|
||||
const seriesConf: uPlot.Series[] = [
|
||||
{},
|
||||
...signals.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: s.color ?? COLORS[i % COLORS.length],
|
||||
width: 1.5,
|
||||
})),
|
||||
];
|
||||
|
||||
uplot = new uPlot(
|
||||
{
|
||||
width: w,
|
||||
height: h,
|
||||
series: seriesConf,
|
||||
legend: { show: showLegend },
|
||||
axes: [
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
],
|
||||
scales: { x: { time: true }, y: scaleY },
|
||||
},
|
||||
uplotData(),
|
||||
el,
|
||||
);
|
||||
} else {
|
||||
if (plotType !== 'timeseries') {
|
||||
echart = echarts.init(el);
|
||||
echart.setOption(echartsOption());
|
||||
}
|
||||
|
||||
// ── Historical mode (timeseries only) ──────────────────────────────────
|
||||
// uPlot is NOT created until data arrives — initialising with empty arrays
|
||||
// causes the x-axis to start at epoch 0, compressing all real data to the
|
||||
// right edge of the chart.
|
||||
if (timeRange && plotType === 'timeseries') {
|
||||
setHistStatus('loading');
|
||||
let cancelled = false;
|
||||
@@ -272,18 +283,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (cancelled) return;
|
||||
const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0);
|
||||
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
|
||||
if (uplot) uplot.setData(uplotData());
|
||||
// Create uPlot now — with real data — so the x-axis is correct from the start.
|
||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setHistStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
uplot?.destroy();
|
||||
echart?.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Live mode: init uPlot immediately ──────────────────────────────────
|
||||
if (plotType === 'timeseries') {
|
||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||
}
|
||||
|
||||
// ── Live streaming mode ────────────────────────────────────────────────
|
||||
setHistStatus('idle');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { formatValue } from '../lib/format';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -32,13 +33,12 @@ export default function TextView({ widget, onContextMenu }: Props) {
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const quality = sv.quality;
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
function displayValue(): string {
|
||||
if (!sv || sv.value === null || sv.value === undefined) return '---';
|
||||
const v = sv.value;
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
if (typeof v === 'number') return formatValue(v, fmt);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user