phase 10
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user