Phase 6
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function BarH({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
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 unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
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 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);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
if (rawValue === null || isNaN(rawValue)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="barh"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
{label && <div class="bar-label">{label}</div>}
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style={`width:${fillPercent()}%;`} />
|
||||
</div>
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{unit && <span class="unit">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function BarV({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
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 unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
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 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);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
if (rawValue === null || isNaN(rawValue)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="barv"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
{label && <div class="bar-label">{label}</div>}
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{unit && <span class="unit">{unit}</span>}
|
||||
</div>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style={`height:${fillPercent()}%;`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { h } from 'preact';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Button({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const label = widget.options['label'] ?? 'Button';
|
||||
const valueOpt = widget.options['value'] ?? '1';
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
|
||||
function handleClick() {
|
||||
if (!sigRef) return;
|
||||
const parsed = parseFloat(valueOpt);
|
||||
const val = isNaN(parsed) ? valueOpt : parsed;
|
||||
const doWrite = () => wsClient.write(sigRef, val);
|
||||
if (confirm) {
|
||||
if (window.confirm(`Send ${label}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="button-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button class="btn" onClick={handleClick}>{label}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
// Arc: -135deg to +135deg (270deg total)
|
||||
const START_DEG = -135;
|
||||
const END_DEG = 135;
|
||||
const TOTAL_DEG = 270;
|
||||
const cx = 50, cy = 54, r = 38;
|
||||
|
||||
function degToRad(deg: number) { return (deg * Math.PI) / 180; }
|
||||
function polarToXY(deg: number, radius: number) {
|
||||
const rad = degToRad(deg);
|
||||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||||
}
|
||||
function arcPath(startDeg: number, endDeg: number, radius: number): string {
|
||||
const s = polarToXY(startDeg, radius);
|
||||
const e = polarToXY(endDeg, radius);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
|
||||
}
|
||||
function valueToDeg(v: number, minVal: number, maxVal: number): number {
|
||||
const clamped = Math.max(minVal, Math.min(maxVal, v));
|
||||
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
|
||||
return START_DEG + frac * TOTAL_DEG;
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Gauge({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
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 unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
|
||||
const thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null;
|
||||
|
||||
const quality = sv.quality;
|
||||
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);
|
||||
}
|
||||
|
||||
function fillColor(): string {
|
||||
if (rawValue === null) return '#4a9eff';
|
||||
if (thresholdHigh !== null && rawValue >= thresholdHigh) return '#ef4444';
|
||||
if (thresholdLow !== null && rawValue <= thresholdLow) return '#f59e0b';
|
||||
return '#4a9eff';
|
||||
}
|
||||
|
||||
const needleDeg = rawValue === null ? START_DEG : valueToDeg(rawValue, minVal, maxVal);
|
||||
const bgPath = arcPath(START_DEG, END_DEG, r);
|
||||
const fillPath = needleDeg > START_DEG ? arcPath(START_DEG, needleDeg, r) : '';
|
||||
const needlePos = polarToXY(needleDeg, r - 4);
|
||||
const fc = fillColor();
|
||||
|
||||
return (
|
||||
<div
|
||||
class="gauge"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<svg viewBox="0 0 100 80" class="gauge-svg">
|
||||
<path d={bgPath} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
|
||||
{fillPath && (
|
||||
<path d={fillPath} fill="none" stroke={fc} stroke-width="6" stroke-linecap="round" />
|
||||
)}
|
||||
<circle cx={needlePos.x} cy={needlePos.y} r="3" fill={fc} />
|
||||
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
|
||||
{unit && <text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>}
|
||||
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
|
||||
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
||||
</svg>
|
||||
{label && <div class="gauge-label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { h } from 'preact';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function ImageWidget({ widget, onContextMenu }: Props) {
|
||||
const url = widget.options['url'] ?? '';
|
||||
const fit = widget.options['fit'] ?? 'contain'; // contain | cover | fill
|
||||
const alt = widget.options['alt'] ?? '';
|
||||
const border = widget.options['border'] !== 'false';
|
||||
|
||||
return (
|
||||
<div
|
||||
class="image-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;${border ? '' : 'border:none;'}`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{url
|
||||
? <img src={url} alt={alt} style={`object-fit:${fit};width:100%;height:100%;display:block;`} />
|
||||
: <span class="image-placeholder">No URL</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function evaluateCondition(expr: string, v: any): boolean {
|
||||
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) return false;
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function('value', 'return ' + expr)(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Led({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsub = getSignalStore(sigRef).subscribe(setSv);
|
||||
return unsub;
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const condition = widget.options['condition'] ?? 'value > 0';
|
||||
const colorTrue = widget.options['colorTrue'] ?? '#22c55e';
|
||||
const colorFalse = widget.options['colorFalse'] ?? '#ef4444';
|
||||
|
||||
const quality = sv.quality;
|
||||
const isUncertain = quality === 'uncertain' || quality === 'unknown';
|
||||
const ledOn = sv.value !== null && sv.value !== undefined ? evaluateCondition(condition, sv.value) : false;
|
||||
const ledColor = ledOn ? colorTrue : colorFalse;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="led-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div
|
||||
class={`led-circle${isUncertain ? ' blink' : ''}`}
|
||||
style={`background:${ledColor};box-shadow:0 0 8px ${ledColor}88;`}
|
||||
/>
|
||||
{label && <div class="led-label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { h } from 'preact';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
widget: Widget;
|
||||
onContextMenu?: (e: MouseEvent) => void;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
}
|
||||
|
||||
export default function LinkWidget({ widget, onContextMenu, onNavigate }: Props) {
|
||||
const target = widget.options['target'] ?? '';
|
||||
const label = widget.options['label'] ?? target ?? 'Open';
|
||||
const icon = widget.options['icon'] ?? '↗';
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (target && onNavigate) onNavigate(target);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="link-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button class="link-btn" onClick={handleClick} title={`Navigate to: ${target}`}>
|
||||
<span class="link-icon">{icon}</span>
|
||||
<span class="link-label">{label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function getBit(val: number, bit: number): boolean {
|
||||
return (val & (1 << bit)) !== 0;
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function MultiLed({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsub = getSignalStore(sigRef).subscribe(setSv);
|
||||
return unsub;
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const bits = parseInt(widget.options['bits'] ?? '8', 10);
|
||||
const labelsRaw = widget.options['labels'] ?? '';
|
||||
const colorsTrueRaw = widget.options['colorsTrue'] ?? '';
|
||||
const colorsFalseRaw = widget.options['colorsFalse'] ?? '';
|
||||
|
||||
const labelArr = labelsRaw ? labelsRaw.split(',') : [];
|
||||
const colorsTrueArr = colorsTrueRaw ? colorsTrueRaw.split(',') : [];
|
||||
const colorsFalseArr = colorsFalseRaw ? colorsFalseRaw.split(',') : [];
|
||||
|
||||
const quality = sv.quality;
|
||||
const isUncertain = quality === 'uncertain' || quality === 'unknown';
|
||||
|
||||
const rawV = sv.value;
|
||||
const intValue = rawV === null || rawV === undefined ? 0
|
||||
: typeof rawV === 'number' ? Math.floor(rawV) : parseInt(String(rawV), 10);
|
||||
|
||||
const bitStates = Array.from({ length: bits }, (_, i) => getBit(intValue, i));
|
||||
|
||||
return (
|
||||
<div
|
||||
class="multiled-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{bitStates.map((on, i) => {
|
||||
const trueColor = colorsTrueArr[i] ?? '#22c55e';
|
||||
const falseColor = colorsFalseArr[i] ?? '#ef4444';
|
||||
const color = on ? trueColor : falseColor;
|
||||
const bitLabel = labelArr[i] ?? String(i);
|
||||
return (
|
||||
<div key={i} class="bit-item">
|
||||
<div
|
||||
class={`led-circle${isUncertain ? ' blink' : ''}`}
|
||||
style={`background:${color};box-shadow:0 0 6px ${color}88;`}
|
||||
/>
|
||||
<div class="bit-label">{bitLabel}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import * as echarts from 'echarts';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalRef } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
interface SeriesBuffer {
|
||||
timestamps: number[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
const RING_MAX = 4000;
|
||||
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||
|
||||
const ECHARTS_DARK = {
|
||||
backgroundColor: '#1a1f2e',
|
||||
textStyle: { color: '#94a3b8' },
|
||||
axisLineStyle: { color: '#475569' },
|
||||
axisLabelStyle: { color: '#64748b' },
|
||||
splitLineStyle: { color: '#2d3748' },
|
||||
};
|
||||
|
||||
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
|
||||
buf.timestamps.push(ts);
|
||||
buf.values.push(val);
|
||||
if (buf.timestamps.length > RING_MAX) {
|
||||
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
|
||||
buf.values.splice(0, buf.values.length - RING_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function windowedSlice(buf: SeriesBuffer, windowSec: number) {
|
||||
const cutoff = Date.now() / 1000 - windowSec;
|
||||
const start = buf.timestamps.findIndex(t => t >= cutoff);
|
||||
if (start === -1) return { ts: [] as number[], vals: [] as number[] };
|
||||
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
||||
}
|
||||
|
||||
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
|
||||
const BUCKETS = 20;
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const vals of allVals) for (const v of vals) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
if (!isFinite(lo) || lo === hi) return { labels: [], counts: allVals.map(() => []) };
|
||||
const size = (hi - lo) / BUCKETS;
|
||||
const counts = allVals.map(vals => {
|
||||
const bins = new Array<number>(BUCKETS).fill(0);
|
||||
for (const v of vals) bins[Math.min(BUCKETS - 1, Math.floor((v - lo) / size))]++;
|
||||
return bins;
|
||||
});
|
||||
const labels = Array.from({ length: BUCKETS }, (_, i) => (lo + i * size).toPrecision(3));
|
||||
return { labels, counts };
|
||||
}
|
||||
|
||||
export default function PlotWidget({ widget, onContextMenu }: Props) {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const signals = widget.signals;
|
||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||
const timeWindow = 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: [] }));
|
||||
const histValues: number[][] = signals.map(() => []);
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
||||
let uplot: uPlot | null = null;
|
||||
|
||||
function uplotData(): uPlot.AlignedData {
|
||||
if (signals.length === 0) return [[]];
|
||||
const allTs = new Set<number>();
|
||||
buffers.forEach(b => windowedSlice(b, timeWindow).ts.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]));
|
||||
return m;
|
||||
});
|
||||
return [
|
||||
tsSorted,
|
||||
...tsMap.map(m => tsSorted.map(t => m.get(t) ?? null)),
|
||||
] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// ── ECharts ────────────────────────────────────────────────────────────
|
||||
let echart: echarts.EChartsType | null = null;
|
||||
|
||||
function echartsOption(): any {
|
||||
const axisLine = { lineStyle: { color: '#475569' } };
|
||||
const axisLabel = { color: '#64748b' };
|
||||
const splitLine = { lineStyle: { color: '#2d3748' } };
|
||||
const legendOpt = showLegend
|
||||
? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } }
|
||||
: undefined;
|
||||
|
||||
switch (plotType) {
|
||||
case 'histogram': {
|
||||
const { labels, counts } = buildHistogram(histValues);
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: counts.map((d, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'bar',
|
||||
data: d,
|
||||
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'bar': {
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: buffers.map((b, i) => ({
|
||||
value: b.values.length ? b.values[b.values.length - 1] : 0,
|
||||
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
})),
|
||||
}],
|
||||
};
|
||||
}
|
||||
case 'fft': {
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data: b.values.length ? b.values[b.values.length - 1] : [],
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'logic': {
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line', step: 'end',
|
||||
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'waterfall': {
|
||||
const ROWS = 32;
|
||||
const b = buffers[0];
|
||||
const step = Math.max(1, Math.floor(b.values.length / ROWS));
|
||||
const flatData: [number, number, number][] = [];
|
||||
for (let ri = 0; ri < ROWS; ri++) {
|
||||
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step;
|
||||
if (idx < 0) continue;
|
||||
const row = b.values[idx];
|
||||
const arr = Array.isArray(row) ? row : [row];
|
||||
arr.forEach((val, ci) => flatData.push([ci, ri, val]));
|
||||
}
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
||||
xAxis: { type: 'value', axisLabel },
|
||||
yAxis: { type: 'value', axisLabel },
|
||||
series: [{ type: 'heatmap', data: flatData }],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { backgroundColor: ECHARTS_DARK.backgroundColor };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init chart ─────────────────────────────────────────────────────────
|
||||
const el = chartRef.current;
|
||||
const w = el.clientWidth || widget.w;
|
||||
const h = el.clientHeight || widget.h;
|
||||
|
||||
if (plotType === 'timeseries') {
|
||||
const seriesConf: uPlot.Series[] = [
|
||||
{},
|
||||
...signals.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: s.color ?? COLORS[i % COLORS.length],
|
||||
width: 1.5,
|
||||
})),
|
||||
];
|
||||
|
||||
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,
|
||||
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 {
|
||||
echart = echarts.init(el);
|
||||
echart.setOption(echartsOption());
|
||||
}
|
||||
|
||||
// ── Subscribe to signals ───────────────────────────────────────────────
|
||||
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 });
|
||||
});
|
||||
unsubs.push(unsub);
|
||||
});
|
||||
|
||||
// ── Resize ─────────────────────────────────────────────────────────────
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!chartRef.current) return;
|
||||
const nw = chartRef.current.clientWidth;
|
||||
const nh = chartRef.current.clientHeight;
|
||||
if (nw > 0 && nh > 0) {
|
||||
if (uplot) uplot.setSize({ width: nw, height: nh });
|
||||
if (echart) echart.resize();
|
||||
}
|
||||
});
|
||||
if (outerRef.current) ro.observe(outerRef.current);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
unsubs.forEach(u => u());
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
};
|
||||
}, [widget.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="plot-widget"
|
||||
ref={outerRef}
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
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 unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
const isNumeric = meta ? meta.type !== 'TypeString' : true;
|
||||
const quality = sv.quality;
|
||||
|
||||
function displayValue(): string {
|
||||
const v = sv.value;
|
||||
if (v === null || v === undefined) return '---';
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function handleSet() {
|
||||
if (!sigRef) return;
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const doWrite = () => {
|
||||
const val = isNumeric ? parseFloat(raw) : raw;
|
||||
wsClient.write(sigRef, val);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
if (confirm) {
|
||||
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSet();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="setvalue"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<span class="sv-label">{label}:</span>
|
||||
<input
|
||||
class="sv-input"
|
||||
type={isNumeric ? 'number' : 'text'}
|
||||
value={inputValue}
|
||||
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
<span class="sv-current">{displayValue()}</span>
|
||||
{unit && <span class="sv-unit">{unit}</span>}
|
||||
<button class="sv-btn" onClick={handleSet}>Set</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { h } from 'preact';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function TextLabel({ widget, onContextMenu }: Props) {
|
||||
const text = widget.options['text'] ?? '';
|
||||
const fontSize = widget.options['fontSize'] ?? '1rem';
|
||||
const color = widget.options['color'] ?? '#e2e8f0';
|
||||
const align = widget.options['align'] ?? 'left';
|
||||
|
||||
return (
|
||||
<div
|
||||
class="textlabel"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;font-size:${fontSize};color:${color};text-align:${align};`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function TextView({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
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 unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const quality = sv.quality;
|
||||
|
||||
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);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="textview"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<span class="label">{label}:</span>
|
||||
<span class="value">{displayValue()}</span>
|
||||
{unit && <span class="unit">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user