Improved UI

This commit is contained in:
Martino Ferrari
2026-05-06 15:55:45 +02:00
parent 0a5a85e4c4
commit 912ecdd9ed
19 changed files with 1141 additions and 279 deletions
+13 -10
View File
@@ -15,11 +15,12 @@ function qualityColor(q: string): string {
}
}
// Arc: -135deg to +135deg (270deg total)
const START_DEG = -135;
const END_DEG = 135;
// Arc: 135° (lower-left, 7 o'clock = min) → 45° (lower-right, 5 o'clock = max),
// sweeping clockwise through the top (12 o'clock). 270° total sweep.
const START_DEG = 135;
const END_DEG = 45;
const TOTAL_DEG = 270;
const cx = 50, cy = 54, r = 38;
const cx = 50, cy = 50, r = 38;
function degToRad(deg: number) { return (deg * Math.PI) / 180; }
function polarToXY(deg: number, radius: number) {
@@ -29,7 +30,9 @@ function polarToXY(deg: number, radius: number) {
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;
// Compute the clockwise angular span so wrap-around works correctly.
const span = ((endDeg - startDeg) % 360 + 360) % 360;
const largeArc = span > 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 {
@@ -91,16 +94,16 @@ export default function Gauge({ widget, onContextMenu }: Props) {
onContextMenu={onContextMenu}
>
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
<svg viewBox="0 0 100 80" class="gauge-svg">
<svg viewBox="0 0 100 90" 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>
<text x={cx} y={cy + 6} text-anchor="middle" class="gauge-value">{displayValue()}</text>
{unit && <text x={cx} y={cy + 17} text-anchor="middle" class="gauge-unit">{unit}</text>}
<text x="14" y="80" text-anchor="middle" class="gauge-range">{minVal}</text>
<text x="86" y="80" text-anchor="middle" class="gauge-range">{maxVal}</text>
</svg>
{label && <div class="gauge-label">{label}</div>}
</div>
+45 -19
View File
@@ -18,7 +18,7 @@ interface SeriesBuffer {
values: number[];
}
const RING_MAX = 4000;
const RING_MAX = 200_000;
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
const ECHARTS_DARK = {
@@ -91,6 +91,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]];
const cutoff = windowSec > 0 ? Date.now() / 1000 - windowSec : -Infinity;
const allTs = new Set<number>();
buffers.forEach(b => {
const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps;
@@ -100,46 +102,68 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (tsSorted.length === 0) {
return [[], ...signals.map(() => [])] as uPlot.AlignedData;
}
const tsMap = buffers.map(b => {
const m = new Map<number, number>();
const pts = windowSec > 0 ? windowedSlice(b, windowSec) : { ts: b.timestamps, vals: b.values };
pts.ts.forEach((t, i) => m.set(t, pts.vals[i]));
return m;
});
// Step-hold interpolation: carry the most recent sample forward so signals
// with different update rates align correctly on the shared time axis.
return [
tsSorted,
...tsMap.map(m => tsSorted.map(t => m.get(t) ?? null)),
...buffers.map(b => {
if (b.timestamps.length === 0) return tsSorted.map(() => null);
let bi = 0;
let hold: number | null = null;
while (bi < b.timestamps.length && b.timestamps[bi] < cutoff) {
hold = b.values[bi++];
}
return tsSorted.map(t => {
while (bi < b.timestamps.length && b.timestamps[bi] <= t) {
hold = b.values[bi++];
}
return hold;
});
}),
] as uPlot.AlignedData;
}
function makeUplotOpts(): uPlot.Options {
const yAxis: uPlot.Axis = {
stroke: '#64748b',
stroke: '#94a3b8',
grid: { stroke: '#2d3748' },
ticks: { stroke: '#475569' },
size: 55,
};
if (fmt) {
yAxis.values = (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => (v === null || v === undefined) ? '' : formatValue(v, fmt));
}
const scaleYRange = (_u: uPlot, min: number, max: number): [number, number] => {
if (!isFinite(min) || !isFinite(max) || min === max) {
const mid = isFinite(min) ? min : 0;
return [mid - 1, mid + 1];
}
return [min, max];
};
const seriesConf: uPlot.Series[] = [
{},
...signals.map((s, i) => ({
label: s.name,
stroke: s.color ?? COLORS[i % COLORS.length],
width: 1.5,
})),
...signals.map((s, i) => {
const color = s.color ?? COLORS[i % COLORS.length];
return {
label: s.name,
stroke: color,
width: 1.5,
points: { show: true, size: 5, stroke: color, fill: color },
};
}),
];
return {
width: w,
height: h,
height: Math.max(50, h - legendH),
series: seriesConf,
legend: { show: showLegend },
axes: [
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
yAxis,
],
scales: { x: { time: true }, y: scaleY },
scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } },
};
}
@@ -247,6 +271,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const el = chartRef.current;
const w = el.clientWidth || widget.w;
const h = el.clientHeight || widget.h;
// Reserve vertical space for the uPlot legend so it isn't clipped.
const legendH = showLegend ? 30 : 0;
const scaleY: uPlot.Scale = {};
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
@@ -334,7 +360,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const nw = chartRef.current.clientWidth;
const nh = chartRef.current.clientHeight;
if (nw > 0 && nh > 0) {
if (uplot) uplot.setSize({ width: nw, height: nh });
if (uplot) uplot.setSize({ width: nw, height: Math.max(50, nh - legendH) });
if (echart) echart.resize();
}
});
@@ -346,7 +372,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}, [widget.id, timeRangeKey, plotType, signalsKey]);
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '']);
return (
<div
+64 -21
View File
@@ -1,4 +1,4 @@
import { h } from 'preact';
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
@@ -35,32 +35,40 @@ export default function SetValue({ widget, onContextMenu }: Props) {
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const confirm = widget.options['confirm'] === 'true';
const isNumeric = meta ? meta.type !== 'string' : true;
const isEnum = !!(meta?.enumStrings && meta.enumStrings.length > 0);
const quality = sv.quality;
function displayValue(): string {
const v = sv.value;
if (v === null || v === undefined) return '---';
if (isEnum && meta?.enumStrings) {
const idx = typeof v === 'number' ? Math.round(v) : parseInt(String(v), 10);
return meta.enumStrings[idx] ?? String(v);
}
if (typeof v === 'number') {
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
}
return String(v);
}
function doWrite(raw: string) {
if (!sigRef || !raw.trim()) return;
const val = isNumeric ? parseFloat(raw) : raw;
wsClient.write(sigRef, val);
setInputValue('');
}
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();
const display = isEnum && meta?.enumStrings
? meta.enumStrings[parseInt(raw, 10)] ?? raw
: raw;
if (window.confirm(`Set ${label} to ${display}?`)) doWrite(raw);
} else {
doWrite();
doWrite(raw);
}
}
@@ -68,6 +76,24 @@ export default function SetValue({ widget, onContextMenu }: Props) {
if (e.key === 'Enter') handleSet();
}
const currentEnumIdx = isEnum && sv.value !== null
? Math.round(typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)))
: -1;
// Tracks the user's selection in the dropdown before they click Set
const [enumSelected, setEnumSelected] = useState(currentEnumIdx >= 0 ? String(currentEnumIdx) : '0');
function handleEnumSet() {
if (!sigRef) return;
const doEnumWrite = () => { wsClient.write(sigRef, parseFloat(enumSelected)); };
if (confirm) {
const display = meta?.enumStrings?.[parseInt(enumSelected, 10)] ?? enumSelected;
if (window.confirm(`Set ${label} to ${display}?`)) doEnumWrite();
} else {
doEnumWrite();
}
}
return (
<div
class="setvalue"
@@ -76,17 +102,34 @@ export default function SetValue({ widget, onContextMenu }: Props) {
>
<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>
{isEnum ? (
<>
<select
class="sv-input sv-select"
value={enumSelected}
onChange={(e: Event) => setEnumSelected((e.target as HTMLSelectElement).value)}
>
{meta!.enumStrings!.map((s, i) => (
<option key={i} value={String(i)}>{s}</option>
))}
</select>
<button class="sv-btn" onClick={handleEnumSet}>Set</button>
</>
) : (
<>
<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>
);
}