This commit is contained in:
Martino Ferrari
2026-04-25 22:56:09 +02:00
parent 8b548ba1c2
commit 986f6cd6d8
85 changed files with 11479 additions and 5050 deletions
+69
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+286
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}