This commit is contained in:
Martino Ferrari
2026-04-26 11:01:55 +02:00
parent 91b42027c9
commit e83e183673
17 changed files with 1009 additions and 182 deletions
+88 -21
View File
@@ -1,11 +1,16 @@
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import uPlot from 'uplot';
import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
import type { Widget, SignalRef } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
interface Props {
widget: Widget;
onContextMenu?: (e: MouseEvent) => void;
timeRange?: { start: string; end: string } | null;
}
interface SeriesBuffer {
timestamps: number[];
@@ -54,9 +59,14 @@ function buildHistogram(allVals: number[][]): { labels: string[]; counts: number
return { labels, counts };
}
export default function PlotWidget({ widget, onContextMenu }: Props) {
export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) {
const outerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<HTMLDivElement>(null);
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
// Re-render chart when switching between live and historical mode.
// We stringify timeRange so the effect dependency is a stable primitive.
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
useEffect(() => {
if (!chartRef.current) return;
@@ -76,19 +86,22 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
// ── uPlot (timeseries) ──────────────────────────────────────────────────
let uplot: uPlot | null = null;
function uplotData(): uPlot.AlignedData {
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]];
const allTs = new Set<number>();
buffers.forEach(b => windowedSlice(b, timeWindow).ts.forEach(t => allTs.add(t)));
buffers.forEach(b => {
const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps;
pts.forEach(t => allTs.add(t));
});
const tsSorted = Array.from(allTs).sort((a, b) => a - b);
if (tsSorted.length === 0) {
// Return proper empty structure: [timestamps, ...series]
return [[], ...signals.map(() => [])] as uPlot.AlignedData;
}
const tsMap = buffers.map(b => {
const m = new Map<number, number>();
const { ts, vals } = windowedSlice(b, timeWindow);
ts.forEach((t, i) => m.set(t, vals[i]));
const pts = windowSec > 0 ? windowedSlice(b, windowSec) : { ts: b.timestamps, vals: b.values };
pts.ts.forEach((t, i) => m.set(t, pts.vals[i]));
return m;
});
return [
@@ -202,6 +215,10 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
const w = el.clientWidth || widget.w;
const h = el.clientHeight || widget.h;
const scaleY: uPlot.Scale = {};
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[] = [
{},
@@ -212,10 +229,6 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
})),
];
const scaleY: uPlot.Scale = {};
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
uplot = new uPlot(
{
width: w,
@@ -236,19 +249,64 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
echart.setOption(echartsOption());
}
// ── Subscribe to signals ───────────────────────────────────────────────
// ── Historical mode (timeseries only) ──────────────────────────────────
if (timeRange && plotType === 'timeseries') {
setHistStatus('loading');
let cancelled = false;
Promise.all(
signals.map((sig: SignalRef & { color?: string }, i) =>
wsClient.history(sig, timeRange.start, timeRange.end, 10_000).then(pts => {
if (cancelled) return;
for (const p of pts) {
const ts = new Date(p.ts).getTime() / 1000;
const v = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
if (!isNaN(v)) {
buffers[i].timestamps.push(ts);
buffers[i].values.push(v);
}
}
}),
),
).then(() => {
if (cancelled) return;
const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0);
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
if (uplot) uplot.setData(uplotData());
}).catch(() => {
if (!cancelled) setHistStatus('error');
});
return () => {
cancelled = true;
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}
// ── Live streaming mode ────────────────────────────────────────────────
setHistStatus('idle');
signals.forEach((sig: SignalRef & { color?: string }, i) => {
const unsub = getSignalStore(sig).subscribe(sv => {
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return;
pushSample(buffers[i], ts, v);
if (plotType === 'histogram') histValues[i].push(v);
if (uplot) uplot.setData(uplotData());
else if (echart) echart.setOption(echartsOption(), { notMerge: true });
if (plotType === 'timeseries') {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return;
pushSample(buffers[i], ts, v);
if (uplot) {
uplot.setData(uplotData(timeWindow));
}
} else {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (!isNaN(v)) {
pushSample(buffers[i], ts, v);
if (plotType === 'histogram') histValues[i].push(v);
}
if (echart) echart.setOption(echartsOption(), { notMerge: true });
}
});
unsubs.push(unsub);
});
@@ -271,7 +329,7 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}, [widget.id]);
}, [widget.id, timeRangeKey]);
return (
<div
@@ -281,6 +339,15 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
onContextMenu={onContextMenu}
>
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
{histStatus === 'loading' && (
<div class="hist-overlay">Loading history</div>
)}
{histStatus === 'empty' && (
<div class="hist-overlay hist-overlay-warn">No archive data for this range</div>
)}
{histStatus === 'error' && (
<div class="hist-overlay hist-overlay-warn">Archive unavailable</div>
)}
</div>
);
}