Improved plot view

This commit is contained in:
Martino Ferrari
2026-06-22 00:30:15 +02:00
parent 113e5a0fe8
commit 73fcbe7b28
14 changed files with 744 additions and 61 deletions
+285 -7
View File
@@ -7,6 +7,7 @@ import { getWidgetCmdStore } from '../lib/widgetCommands';
import { wsClient } from '../lib/ws';
import { formatValue } from '../lib/format';
import { fftMag, resampleUniform } from '../lib/fft';
import { PLOT_SYNC_KEY, subscribeXRange, broadcastXRange } from '../lib/plotSync';
import type { Widget, SignalRef } from '../lib/types';
interface Props {
@@ -20,6 +21,19 @@ interface SeriesBuffer {
values: number[];
}
interface MeasureRow {
name: string;
color: string;
va: number | null;
vb: number | null;
}
interface MeasureReadout {
a: number; // cursor A time (seconds)
b: number | null; // cursor B time (seconds), once placed
rows: MeasureRow[];
}
const RING_MAX = 200_000;
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
@@ -47,6 +61,39 @@ function windowedSlice(buf: SeriesBuffer, windowSec: number) {
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
}
// Compact display of a measured value (null → em-dash).
function fmtNum(v: number | null): string {
if (v == null || !isFinite(v)) return '—';
const a = Math.abs(v);
if (a !== 0 && (a < 1e-3 || a >= 1e6)) return v.toExponential(3);
return v.toFixed(Math.abs(v) >= 100 ? 1 : 3);
}
// Human-readable Δt between two cursors (seconds), with rate where useful.
function formatDt(dt: number): string {
const a = Math.abs(dt);
if (a < 1) return `${(dt * 1000).toFixed(1)} ms`;
if (a < 60) return `${dt.toFixed(3)} s (${(1 / dt).toFixed(2)} Hz)`;
if (a < 3600) return `${(dt / 60).toFixed(2)} min`;
return `${(dt / 3600).toFixed(2)} h`;
}
// Step-hold value of a series at time t (seconds): the most recent sample whose
// timestamp is <= t. Used by the measurement cursors. Binary search over the
// (sorted) timestamp buffer.
function valueAt(buf: SeriesBuffer, t: number): number | null {
const ts = buf.timestamps;
if (ts.length === 0) return null;
let lo = 0, hi = ts.length - 1, res = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (ts[mid] <= t) { res = mid; lo = mid + 1; }
else hi = mid - 1;
}
if (res === -1) return null; // t is before the first sample
return buf.values[res];
}
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
const BUCKETS = 20;
let lo = Infinity, hi = -Infinity;
@@ -67,6 +114,108 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const chartRef = useRef<HTMLDivElement>(null);
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
// Live chart instances + sample buffers held in refs so the hover toolbar can
// pause/clear/screenshot without tearing down and recreating the chart. The
// buffers persist across effect re-runs (plot-type / window / legend changes),
// keyed by widget+signals+time-range so only a genuine data-source change
// resets them.
const uplotRef = useRef<uPlot | null>(null);
const echartRef = useRef<echarts.EChartsType | null>(null);
const buffersRef = useRef<{ key: string; bufs: SeriesBuffer[]; waves: number[][] } | null>(null);
// Toolbar state: local pause (OR'd with panel-logic pause) and a view-time
// rolling-window override (null = use the configured timeWindow option).
const [paused, setPaused] = useState(false);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
const [winOverride, setWinOverride] = useState<number | null>(null);
// Cross-plot link: when on, this plot joins the shared cursor + x-range sync
// group (see lib/plotSync). Toggling re-inits the chart (cursor.sync is an
// init-time uPlot option); buffers persist via buffersRef.
const [linked, setLinked] = useState(true);
// Measurement cursors: marksRef holds two x-times (A/B) on the time axis; the
// readout panel shows Δt plus each series' value at A, B and ΔV. Toggling
// measure mode does NOT re-init the chart — the draw hook and click handler
// read live refs and a redraw() is enough.
const [measureOn, setMeasureOn] = useState(false);
const measureOnRef = useRef(false);
const marksRef = useRef<{ a: number | null; b: number | null }>({ a: null, b: null });
const [readout, setReadout] = useState<MeasureReadout | null>(null);
// Guards a feedback loop when applying a synced x-range back into uPlot.
const applyingSyncRef = useRef(false);
// Recompute the measurement readout from the current marks + buffers.
function refreshReadout() {
const { a, b } = marksRef.current;
const b0 = buffersRef.current?.bufs;
if (a == null || !b0) { setReadout(null); return; }
const rows: MeasureRow[] = widget.signals.map((s, i) => ({
name: s.name,
color: s.color ?? COLORS[i % COLORS.length],
va: b0[i] ? valueAt(b0[i], a) : null,
vb: b != null && b0[i] ? valueAt(b0[i], b) : null,
}));
setReadout({ a, b, rows });
}
// Place / move measurement cursors on click while in measure mode.
function onMeasureClick() {
const u = uplotRef.current;
const left = u ? (u as any).cursor?.left : null;
if (!measureOnRef.current || !u || left == null || left < 0) return;
const val = (u as any).posToVal(left, 'x');
const m = marksRef.current;
// 1st click (or restart after both set) → A; 2nd click → B.
if (m.a == null || m.b != null) { m.a = val; m.b = null; }
else m.b = val;
refreshReadout();
(u as any).redraw();
}
function toggleMeasure() {
const on = !measureOnRef.current;
measureOnRef.current = on;
setMeasureOn(on);
if (!on) { marksRef.current = { a: null, b: null }; setReadout(null); }
(uplotRef.current as any)?.redraw();
}
// ── Toolbar handlers (operate on the live ref instances) ──────────────────
function clearPlot() {
const b = buffersRef.current;
if (!b) return;
b.bufs.forEach(buf => { buf.timestamps.length = 0; buf.values.length = 0; });
b.waves.forEach(w => { w.length = 0; });
uplotRef.current?.setData([[], ...b.bufs.map(() => [])] as uPlot.AlignedData);
}
function screenshot() {
let dataUrl: string | undefined;
const canvas = chartRef.current?.querySelector('canvas');
if (uplotRef.current && canvas) {
dataUrl = canvas.toDataURL('image/png');
} else if (echartRef.current) {
dataUrl = (echartRef.current as any).getDataURL({ pixelRatio: 2, backgroundColor: '#1a1f2e' });
}
if (!dataUrl) return;
const name = (widget.signals[0]?.name ?? 'plot').replace(/[^\w.-]+/g, '_');
const a = document.createElement('a');
a.href = dataUrl;
a.download = `${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
a.click();
}
// Rolling-window presets (seconds); null = use the widget's configured window.
const WINDOW_PRESETS: { label: string; val: number | null }[] = [
{ label: 'Auto', val: null },
{ label: '15s', val: 15 },
{ label: '30s', val: 30 },
{ label: '1m', val: 60 },
{ label: '5m', val: 300 },
{ label: '15m', val: 900 },
{ label: '1h', val: 3600 },
];
// Stable primitive deps so the effect re-runs when plotType or signals change.
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
const plotType = widget.options['plotType'] ?? 'timeseries';
@@ -77,15 +226,26 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (!chartRef.current) return;
const signals = widget.signals;
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
const timeWindow = winOverride ?? parseFloat(widget.options['timeWindow'] ?? '60');
const yMin = widget.options['yMin'];
const yMax = widget.options['yMax'];
const legendPos = widget.options['legend'] ?? 'bottom';
const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
// Reuse persisted buffers across effect re-runs (plot-type / window / legend
// changes) so the toolbar and a window switch don't drop live history; reset
// only when the widget, its signals or the time-range change.
const bufKey = `${widget.id}|${signalsKey}|${timeRangeKey}`;
if (!buffersRef.current || buffersRef.current.key !== bufKey) {
buffersRef.current = {
key: bufKey,
bufs: signals.map(() => ({ timestamps: [], values: [] })),
waves: signals.map(() => []),
};
}
const buffers: SeriesBuffer[] = buffersRef.current.bufs;
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
const waveforms: number[][] = signals.map(() => []);
const waveforms: number[][] = buffersRef.current.waves;
const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? '';
@@ -139,6 +299,29 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
] as uPlot.AlignedData;
}
// Draw the measurement cursor lines (A=amber, B=green) onto the uPlot canvas
// so they track the data on zoom / pan / resize. Runs in the 'draw' hook.
function drawMeasureMarks(u: uPlot) {
const { a, b } = marksRef.current;
if (a == null && b == null) return;
const ctx = (u as any).ctx as CanvasRenderingContext2D;
const bb = (u as any).bbox as { top: number; height: number };
ctx.save();
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
const line = (val: number, color: string) => {
const x = (u as any).valToPos(val, 'x', true);
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(x, bb.top);
ctx.lineTo(x, bb.top + bb.height);
ctx.stroke();
};
if (a != null) line(a, '#f59e0b');
if (b != null) line(b, '#22c55e');
ctx.restore();
}
function makeUplotOpts(): uPlot.Options {
const yAxis: uPlot.Axis = {
stroke: '#94a3b8',
@@ -194,6 +377,21 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
},
y: { ...scaleY, range: scaleYRange },
},
// Cross-plot crosshair sync (x only) for all linked plots.
cursor: linked ? { sync: { key: PLOT_SYNC_KEY, scales: ['x', null] } } : {},
hooks: {
draw: [drawMeasureMarks],
// Broadcast zoom/pan to other linked plots (historical mode only — live
// mode auto-scrolls its own rolling window). Guard against the echo of
// an applied sync to avoid a feedback loop.
setScale: [(u: uPlot, key: string) => {
if (key !== 'x' || !linked || !timeRange || applyingSyncRef.current) return;
const sc = (u as any).scales.x as { min: number | null; max: number | null };
if (sc.min != null && sc.max != null) {
broadcastXRange(widget.id, { min: sc.min, max: sc.max });
}
}],
},
};
}
@@ -373,6 +571,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (plotType !== 'timeseries') {
echart = echarts.init(el);
echart.setOption(echartsOption());
echartRef.current = echart;
}
// Wire a freshly-created uPlot into the refs + measurement click handler.
function mountUplot(u: uPlot) {
uplotRef.current = u;
(u as any).over.addEventListener('click', onMeasureClick);
}
// Apply a synced x-range pushed from a peer plot (historical zoom/pan sync).
if (linked && plotType === 'timeseries') {
unsubs.push(subscribeXRange(widget.id, r => {
const u = uplotRef.current;
if (!u || !timeRange) return;
applyingSyncRef.current = true;
(u as any).setScale('x', { min: r.min, max: r.max });
applyingSyncRef.current = false;
}));
}
// ── Historical mode (timeseries only) ──────────────────────────────────
@@ -403,20 +619,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
// Create uPlot now — with real data — so the x-axis is correct from the start.
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
mountUplot(uplot);
}).catch(() => {
if (!cancelled) setHistStatus('error');
});
return () => {
cancelled = true;
unsubs.forEach(u => u());
uplot?.destroy();
echart?.dispose();
uplotRef.current = null;
echartRef.current = null;
};
}
// ── Live mode: init uPlot immediately ──────────────────────────────────
if (plotType === 'timeseries') {
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
mountUplot(uplot);
}
// ── Live streaming mode ────────────────────────────────────────────────
@@ -425,10 +646,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// Honour action.widget logic commands: `paused` suspends live ingestion and
// a bumped `clearNonce` empties the buffers + redraws once.
const cmdStore = getWidgetCmdStore(widget.id);
let paused = cmdStore.get().paused;
let cmdPaused = cmdStore.get().paused;
let lastClear = cmdStore.get().clearNonce;
unsubs.push(cmdStore.subscribe(cmd => {
paused = cmd.paused;
cmdPaused = cmd.paused;
if (cmd.clearNonce !== lastClear) {
lastClear = cmd.clearNonce;
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
@@ -439,7 +660,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
signals.forEach((sig: SignalRef & { color?: string }, i) => {
const unsub = getSignalStore(sig).subscribe(sv => {
if (paused) return;
if (cmdPaused || pausedRef.current) return;
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
@@ -487,8 +708,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
unsubs.forEach(u => u());
if (uplot) uplot.destroy();
if (echart) echart.dispose();
uplotRef.current = null;
echartRef.current = null;
};
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
}, [widget.id, timeRangeKey, plotType, signalsKey, winOverride, linked, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
return (
<div
@@ -497,6 +720,61 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<div class="plot-toolbar" onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
<button
class={`plot-tb-btn${paused ? ' plot-tb-active' : ''}`}
title={paused ? 'Resume live updates' : 'Pause live updates'}
onClick={() => setPaused(p => !p)}
>{paused ? '▶' : '⏸'}</button>
{!timeRange && (plotType === 'timeseries' || plotType === 'fft' || plotType === 'logic') && (
<select
class="plot-tb-select"
title="Rolling time window"
value={winOverride === null ? 'auto' : String(winOverride)}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
setWinOverride(v === 'auto' ? null : parseFloat(v));
}}
>
{WINDOW_PRESETS.map(p => (
<option key={p.label} value={p.val === null ? 'auto' : String(p.val)}>{p.label}</option>
))}
</select>
)}
{plotType === 'timeseries' && (
<button
class={`plot-tb-btn${linked ? ' plot-tb-active' : ''}`}
title={linked ? 'Linked: cursor / zoom synced with other plots — click to unlink' : 'Unlinked — click to sync cursor / zoom with other plots'}
onClick={() => setLinked(v => !v)}
>{linked ? '🔗' : '⛓️‍💥'}</button>
)}
{plotType === 'timeseries' && (
<button
class={`plot-tb-btn${measureOn ? ' plot-tb-active' : ''}`}
title="Measure: click to place cursor A then B; read Δt and per-signal Δ"
onClick={toggleMeasure}
>📏</button>
)}
<button class="plot-tb-btn" title="Clear buffered data" onClick={clearPlot}>🗑</button>
<button class="plot-tb-btn" title="Save screenshot (PNG)" onClick={screenshot}>📷</button>
</div>
{readout && plotType === 'timeseries' && (
<div class="measure-readout" onMouseDown={(e) => e.stopPropagation()}>
<div class="measure-row measure-head">
<span>Δt</span>
<span>{readout.b != null ? formatDt(readout.b - readout.a) : '—'}</span>
</div>
{readout.rows.map(r => (
<div class="measure-row" key={r.name}>
<span class="measure-name" style={`color:${r.color}`}>{r.name}</span>
<span class="measure-vals">
{fmtNum(r.va)}
{readout.b != null && <span class="measure-delta"> {fmtNum(r.vb)} (Δ {fmtNum(r.va != null && r.vb != null ? r.vb - r.va : null)})</span>}
</span>
</div>
))}
</div>
)}
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
{histStatus === 'loading' && (
<div class="hist-overlay">Loading history</div>
+81
View File
@@ -0,0 +1,81 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
import { useAuth, canWrite } from '../lib/auth';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// Compare a live signal value against a configured on/off target. Numeric when
// both parse as finite numbers (tolerant compare), otherwise string equality.
function matches(value: unknown, target: string): boolean {
if (value === null || value === undefined) return false;
const vn = typeof value === 'number' ? value : parseFloat(String(value));
const tn = parseFloat(target);
if (Number.isFinite(vn) && Number.isFinite(tn)) return Math.abs(vn - tn) < 1e-9;
return String(value) === target;
}
export default function Toggle({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const me = useAuth();
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsubV = getSignalStore(sigRef).subscribe(setSv);
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] || sigRef?.name || '';
const onValue = widget.options['onValue'] ?? '1';
const offValue = widget.options['offValue'] ?? '0';
const onLabel = widget.options['onLabel'] || 'ON';
const offLabel = widget.options['offLabel'] || 'OFF';
const confirm = widget.options['confirm'] === 'true';
const isLocal = sigRef?.ds === 'local';
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
// "On" when the value matches the configured on-target; unknown until a value
// arrives. A value matching neither target is treated as off.
const known = sv.value !== null && sv.value !== undefined;
const isOn = known && matches(sv.value, onValue);
function toggle() {
if (!sigRef || !writeAllowed) return;
const target = isOn ? offValue : onValue;
const numeric = Number.isFinite(parseFloat(target)) && meta?.type !== 'string';
const write = () => wsClient.write(sigRef, numeric ? parseFloat(target) : target);
if (confirm) {
if (window.confirm(`Set ${label} to ${isOn ? offLabel : onLabel}?`)) write();
} else {
write();
}
}
return (
<div
class="toggle-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{label && <span class="toggle-label">{label}</span>}
<button
type="button"
class={`toggle-switch${isOn ? ' toggle-on' : ''}${!known ? ' toggle-unknown' : ''}`}
disabled={!writeAllowed}
onClick={toggle}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}
>
<span class="toggle-track"><span class="toggle-knob" /></span>
<span class="toggle-state">{!known ? '—' : isOn ? onLabel : offLabel}</span>
</button>
</div>
);
}