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