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