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
+2
View File
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import { applyZoom, getStoredZoom } from './ZoomControl';
import type { Interface } from './lib/types';
type AppMode = 'view' | 'edit';
@@ -26,6 +27,7 @@ export default function App() {
useEffect(() => {
const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr));
applyZoom(getStoredZoom());
if (!fsParam) wsClient.connect('/ws');
}, []);
+27 -1
View File
@@ -7,6 +7,7 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
interface Props {
initial: Interface | null;
@@ -83,6 +84,27 @@ export default function EditMode({ initial, onDone }: Props) {
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit');
const [leftW, setLeftW] = useState(220);
const [rightW, setRightW] = useState(260);
function startResize(side: 'left' | 'right') {
return (e: MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = side === 'left' ? leftW : rightW;
function onMove(mv: MouseEvent) {
const dx = mv.clientX - startX;
if (side === 'left') setLeftW(Math.max(140, startW + dx));
else setRightW(Math.max(180, startW - dx));
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
}
function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); }
// Undo / redo
@@ -344,6 +366,7 @@ export default function EditMode({ initial, onDone }: Props) {
</div>
<div class="toolbar-right">
<ZoomControl />
<ContextualHelp mode="edit" onOpenManual={openHelp} />
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
<button class="toolbar-btn" onClick={handleImport}>Import</button>
@@ -357,7 +380,8 @@ export default function EditMode({ initial, onDone }: Props) {
{/* ── Three-panel body ── */}
<div class="edit-body">
<SignalTree />
<SignalTree width={leftW} />
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
<EditCanvas
iface={iface}
selectedIds={selectedIds}
@@ -365,12 +389,14 @@ export default function EditMode({ initial, onDone }: Props) {
onChange={handleWidgetsChange}
snapGrid={snapGrid}
/>
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
<PropertiesPane
selected={selectedWidget}
multiCount={multiSelected ? selectedIds.length : 0}
iface={iface}
onChange={handleWidgetChange}
onIfaceChange={handleIfaceChange}
width={rightW}
/>
</div>
+3 -2
View File
@@ -13,9 +13,10 @@ interface Props {
onSelect?: (id: string) => void;
onEdit?: (iface?: Interface) => void;
onEditId?: (id: string) => void;
width?: number;
}
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) {
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
const [loading, setLoading] = useState(true);
@@ -66,7 +67,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
}
return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
<div class="panel-header">
{!collapsed && <span class="panel-title">Interfaces</span>}
<button
+137
View File
@@ -0,0 +1,137 @@
import { h } from 'preact';
import { useRef } from 'preact/hooks';
// ── Tokenizer ─────────────────────────────────────────────────────────────────
type TT = 'kw' | 'str' | 'cmt' | 'num' | 'text';
const LUA_KEYWORDS = new Set([
'if', 'then', 'else', 'elseif', 'end', 'while', 'do', 'for', 'in',
'return', 'local', 'function', 'not', 'and', 'or', 'nil', 'true',
'false', 'break', 'repeat', 'until',
]);
interface Tok { t: TT; s: string; }
function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_'; }
function isAlNum(c: string) { return isAlpha(c) || (c >= '0' && c <= '9'); }
function isDigit(c: string) { return c >= '0' && c <= '9'; }
function tokenize(src: string): Tok[] {
const out: Tok[] = [];
let i = 0;
while (i < src.length) {
const c = src[i];
// Single-line comment
if (c === '-' && src[i + 1] === '-') {
const end = src.indexOf('\n', i);
const stop = end < 0 ? src.length : end;
out.push({ t: 'cmt', s: src.slice(i, stop) });
i = stop;
continue;
}
// String: "..." or '...'
if (c === '"' || c === "'") {
let j = i + 1;
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue; }
if (src[j] === c) { j++; break; }
j++;
}
out.push({ t: 'str', s: src.slice(i, j) });
i = j;
continue;
}
// Long string [[...]]
if (c === '[' && src[i + 1] === '[') {
const end = src.indexOf(']]', i + 2);
const stop = end < 0 ? src.length : end + 2;
out.push({ t: 'str', s: src.slice(i, stop) });
i = stop;
continue;
}
// Number
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) {
let j = i;
if (src[j] === '0' && (src[j + 1] === 'x' || src[j + 1] === 'X')) {
j += 2;
while (j < src.length && /[0-9a-fA-F]/.test(src[j])) j++;
} else {
while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
if (j < src.length && (src[j] === 'e' || src[j] === 'E')) {
j++;
if (src[j] === '+' || src[j] === '-') j++;
while (j < src.length && isDigit(src[j])) j++;
}
}
out.push({ t: 'num', s: src.slice(i, j) });
i = j;
continue;
}
// Identifier / keyword
if (isAlpha(c)) {
let j = i;
while (j < src.length && isAlNum(src[j])) j++;
const word = src.slice(i, j);
out.push({ t: LUA_KEYWORDS.has(word) ? 'kw' : 'text', s: word });
i = j;
continue;
}
out.push({ t: 'text', s: c });
i++;
}
return out;
}
function renderTokens(src: string) {
return tokenize(src).map((tok, idx) =>
tok.t === 'text'
? <span key={idx}>{tok.s}</span>
: <span key={idx} class={`lua-${tok.t}`}>{tok.s}</span>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
value: string;
onChange: (v: string) => void;
rows?: number;
}
export default function LuaEditor({ value, onChange, rows = 12 }: Props) {
const preRef = useRef<HTMLPreElement>(null);
function syncScroll(e: Event) {
const ta = e.target as HTMLTextAreaElement;
if (preRef.current) {
preRef.current.scrollTop = ta.scrollTop;
preRef.current.scrollLeft = ta.scrollLeft;
}
}
return (
<div class="lua-editor" style={`height:${rows * 1.5}em;`}>
<pre ref={preRef} class="lua-pre" aria-hidden="true">
{renderTokens(value)}
{'\n'}
</pre>
<textarea
class="lua-textarea"
value={value}
spellcheck={false}
autocomplete="off"
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
onScroll={syncScroll}
/>
</div>
);
}
+185 -39
View File
@@ -4,20 +4,31 @@ import uPlot from 'uplot';
import { getSignalStore, getMetaStore } from './lib/stores';
import type { SignalRef, SignalMeta } from './lib/types';
// ── Public types ──────────────────────────────────────────────────────────────
export interface PlotSignalStyle {
color: string;
lineWidth: number; // px; 0 = no line (markers-only)
lineDash: number[]; // [] = solid, [8,4] = dashed, [2,4] = dotted
markerSize: number; // diameter px; 0 = no markers
}
export interface PlotSignal {
ref: SignalRef;
color: string;
style: PlotSignalStyle;
}
interface Props {
signals: PlotSignal[];
onRemove: (ref: SignalRef) => void;
onStyleChange: (ref: SignalRef, style: PlotSignalStyle) => void;
}
// ── Constants ─────────────────────────────────────────────────────────────────
interface Buf { ts: number[]; vals: number[]; }
// Large enough to hold a full 1-hour window at ~10 Hz per signal.
const RING_MAX = 50_000;
const RING_MAX = 200_000;
const TIME_WINDOWS = [
{ label: '10s', secs: 10 },
@@ -28,6 +39,29 @@ const TIME_WINDOWS = [
{ label: '1h', secs: 3600 },
];
const LINE_WIDTHS: Array<{ label: string; value: number }> = [
{ label: '✕', value: 0 },
{ label: '1', value: 1 },
{ label: '1.5', value: 1.5 },
{ label: '2', value: 2 },
{ label: '3', value: 3 },
];
const LINE_DASHES: Array<{ label: string; value: number[] }> = [
{ label: '━━', value: [] },
{ label: '╍╍', value: [8, 4] },
{ label: '⋯', value: [2, 4] },
];
const MARKER_SIZES: Array<{ label: string; value: number }> = [
{ label: '✕', value: 0 },
{ label: 'S', value: 3 },
{ label: 'M', value: 5 },
{ label: 'L', value: 8 },
];
// ── Pure helpers ──────────────────────────────────────────────────────────────
interface Stat { min: number; max: number; mean: number; last: number; }
function pushSample(buf: Buf, t: number, v: number) {
@@ -41,6 +75,7 @@ function pushSample(buf: Buf, t: number, v: number) {
function buildData(signals: PlotSignal[], bufs: Map<string, Buf>, windowSec: number): uPlot.AlignedData {
const cutoff = Date.now() / 1000 - windowSec;
const allTs = new Set<number>();
for (const s of signals) {
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
@@ -48,14 +83,23 @@ function buildData(signals: PlotSignal[], bufs: Map<string, Buf>, windowSec: num
}
const sorted = Array.from(allTs).sort((a, b) => a - b);
if (sorted.length === 0) return [[], ...signals.map(() => [])] as uPlot.AlignedData;
return [
sorted,
...signals.map(s => {
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
if (!buf) return sorted.map(() => null);
const m = new Map<number, number>();
buf.ts.forEach((t, i) => { if (t >= cutoff) m.set(t, buf.vals[i]); });
return sorted.map(t => m.get(t) ?? null);
if (!buf || buf.ts.length === 0) return sorted.map(() => null);
// Step-hold: carry the most recent sample forward so signals with
// different update rates align correctly on the shared time axis.
let bi = 0;
let hold: number | null = null;
while (bi < buf.ts.length && buf.ts[bi] < cutoff) hold = buf.vals[bi++];
return sorted.map(t => {
while (bi < buf.ts.length && buf.ts[bi] <= t) hold = buf.vals[bi++];
return hold;
});
}),
] as uPlot.AlignedData;
}
@@ -78,22 +122,47 @@ function fmtN(v: number | undefined | null): string {
return v.toPrecision(4).replace(/\.?0+$/, '');
}
export default function PlotPanel({ signals, onRemove }: Props) {
function dashEq(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
function styleKey(s: PlotSignal): string {
return `${s.ref.ds}:${s.ref.name}:${s.style.color}:${s.style.lineWidth}:${s.style.lineDash.join(',')}:${s.style.markerSize}`;
}
function makeSeries(s: PlotSignal): uPlot.Series {
const { color, lineWidth, lineDash, markerSize } = s.style;
return {
label: s.ref.name,
stroke: color,
width: lineWidth,
dash: lineDash.length > 0 ? lineDash : undefined,
points: {
show: markerSize > 0,
size: markerSize,
stroke: color,
fill: color,
},
spanGaps: false,
};
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function PlotPanel({ signals, onRemove, onStyleChange }: Props) {
const chartRef = useRef<HTMLDivElement>(null);
const [windowSec, setWindowSec] = useState(60);
const [stats, setStats] = useState<Array<Stat | null>>([]);
const [metas, setMetas] = useState<Map<string, SignalMeta>>(new Map());
const [editingKey, setEditingKey] = useState<string | null>(null);
// Persistent ring buffers survive signal list changes (so we don't lose history)
const bufsRef = useRef<Map<string, Buf>>(new Map());
const signalsKey = signals.map(s => `${s.ref.ds}:${s.ref.name}:${s.color}`).join(',');
const signalsKey = signals.map(styleKey).join('|');
useEffect(() => {
if (!chartRef.current) return;
const el = chartRef.current;
// Ensure buffers exist for all current signals
for (const s of signals) {
const key = `${s.ref.ds}\0${s.ref.name}`;
if (!bufsRef.current.has(key)) bufsRef.current.set(key, { ts: [], vals: [] });
@@ -101,23 +170,21 @@ export default function PlotPanel({ signals, onRemove }: Props) {
if (signals.length === 0) return;
// Must be defined BEFORE new uPlot() because the range function closes over it
// and uPlot calls range() synchronously during construction.
let currentWindow = windowSec;
// Create uPlot
let uplot = new uPlot(
const uplot = new uPlot(
{
width: Math.max(el.clientWidth, 200),
height: Math.max(el.clientHeight, 100),
legend: { show: false },
cursor: { show: true },
axes: [
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
{
stroke: '#64748b',
stroke: '#94a3b8',
grid: { stroke: '#2d3748' },
ticks: { stroke: '#475569' },
size: 55,
values: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => (v === null || v === undefined) ? '' : fmtN(v)),
},
@@ -125,28 +192,28 @@ export default function PlotPanel({ signals, onRemove }: Props) {
scales: {
x: {
time: true,
range: (_u: uPlot, _min: number, _max: number): [number, number] => {
range: (): [number, number] => {
const now = Date.now() / 1000;
return [now - currentWindow, now];
},
},
y: { auto: true },
y: {
auto: true,
range: (_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];
},
},
},
series: [
{},
...signals.map(s => ({
label: s.ref.name,
stroke: s.color,
width: 1.5,
spanGaps: false,
})),
],
series: [{}, ...signals.map(makeSeries)],
},
buildData(signals, bufsRef.current, windowSec),
el,
);
// Subscriptions to signal stores
const unsubs: Array<() => void> = [];
let dirty = false;
@@ -170,13 +237,8 @@ export default function PlotPanel({ signals, onRemove }: Props) {
unsubs.push(unsubM);
});
// Expose a setter for the window so the windowSec useEffect can poke it
(el as any)._setWindow = (secs: number) => { currentWindow = secs; dirty = true; };
// RAF update loop — redraws on new data, every ~1 s to advance the time axis,
// or immediately when the chart becomes visible again after being hidden.
// Skips updates while the chart is hidden (display:none → 0×0) to prevent
// uPlot internal state corruption.
let lastDrawTime = 0;
let wasVisible = false;
let rafId = requestAnimationFrame(function tick(ts) {
@@ -186,6 +248,10 @@ export default function PlotPanel({ signals, onRemove }: Props) {
if (isVisible && (dirty || becameVisible || ts - lastDrawTime >= 1000)) {
dirty = false;
lastDrawTime = ts;
if (becameVisible) {
const nw = el.clientWidth, nh = el.clientHeight;
if (nw > 0 && nh > 0) uplot.setSize({ width: nw, height: nh });
}
uplot.setData(buildData(signals, bufsRef.current, currentWindow));
setStats(signals.map(s =>
computeStats(bufsRef.current.get(`${s.ref.ds}\0${s.ref.name}`), currentWindow)
@@ -194,7 +260,6 @@ export default function PlotPanel({ signals, onRemove }: Props) {
rafId = requestAnimationFrame(tick);
});
// Resize observer
const ro = new ResizeObserver(() => {
const w = el.clientWidth, h = el.clientHeight;
if (w > 0 && h > 0) uplot.setSize({ width: w, height: h });
@@ -210,11 +275,12 @@ export default function PlotPanel({ signals, onRemove }: Props) {
};
}, [signalsKey]);
// When the window selector changes, poke the running chart
useEffect(() => {
if (chartRef.current) (chartRef.current as any)._setWindow?.(windowSec);
}, [windowSec]);
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div class="plot-panel">
<div class="plot-panel-toolbar">
@@ -245,15 +311,34 @@ export default function PlotPanel({ signals, onRemove }: Props) {
const st = stats[i] ?? null;
const meta = metas.get(key);
const unit = meta?.unit ?? '';
const isEditing = editingKey === key;
const { color, lineWidth, lineDash, markerSize } = s.style;
function update(patch: Partial<PlotSignalStyle>) {
onStyleChange(s.ref, { ...s.style, ...patch });
}
return (
<div class="plot-legend-item" key={key}>
{/* Header row */}
<div class="plot-legend-hdr">
<span class="plot-legend-swatch" style={`background:${s.color};`} />
<span class="plot-legend-swatch" style={`background:${color};`} />
<span class="plot-legend-name" title={`${s.ref.ds} / ${s.ref.name}`}>
{s.ref.name}
</span>
<button class="plot-legend-rm" onClick={() => onRemove(s.ref)} title="Remove"></button>
<button
class={`plot-legend-icon-btn${isEditing ? ' active' : ''}`}
onClick={() => setEditingKey(isEditing ? null : key)}
title="Edit style"
></button>
<button
class="plot-legend-icon-btn"
onClick={() => onRemove(s.ref)}
title="Remove"
></button>
</div>
{/* Stats */}
<table class="plot-stats-tbl">
<tbody>
<tr>
@@ -265,6 +350,67 @@ export default function PlotPanel({ signals, onRemove }: Props) {
<tr><td>Mean</td><td>{st ? fmtN(st.mean) : '—'}</td></tr>
</tbody>
</table>
{/* Inline style editor */}
{isEditing && (
<div class="plot-style-editor">
{/* Color */}
<div class="plot-style-row">
<span class="plot-style-label">Color</span>
<input
type="color"
class="plot-style-color"
value={color}
onInput={e => update({ color: (e.target as HTMLInputElement).value })}
/>
</div>
{/* Line width */}
<div class="plot-style-row">
<span class="plot-style-label">Width</span>
<div class="plot-style-btns">
{LINE_WIDTHS.map(lw => (
<button
key={lw.value}
class={`plot-style-btn${lineWidth === lw.value ? ' active' : ''}`}
onClick={() => update({ lineWidth: lw.value })}
title={lw.value === 0 ? 'No line' : `${lw.value}px`}
>{lw.label}</button>
))}
</div>
</div>
{/* Line dash */}
<div class="plot-style-row">
<span class="plot-style-label">Line</span>
<div class="plot-style-btns">
{LINE_DASHES.map(ld => (
<button
key={ld.label}
class={`plot-style-btn${dashEq(lineDash, ld.value) ? ' active' : ''}`}
onClick={() => update({ lineDash: ld.value })}
title={ld.value.length === 0 ? 'Solid' : ld.value[0] > 4 ? 'Dashed' : 'Dotted'}
>{ld.label}</button>
))}
</div>
</div>
{/* Markers */}
<div class="plot-style-row">
<span class="plot-style-label">Markers</span>
<div class="plot-style-btns">
{MARKER_SIZES.map(ms => (
<button
key={ms.value}
class={`plot-style-btn${markerSize === ms.value ? ' active' : ''}`}
onClick={() => update({ markerSize: ms.value })}
title={ms.value === 0 ? 'None' : `Size ${ms.value}`}
>{ms.label}</button>
))}
</div>
</div>
</div>
)}
</div>
);
})}
+3 -2
View File
@@ -8,6 +8,7 @@ interface Props {
iface: Interface;
onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void;
width?: number;
}
function Field({ label, children }: { label: string; children: any }) {
@@ -44,7 +45,7 @@ const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue',
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) {
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
function setOpt(key: string, value: string) {
@@ -66,7 +67,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
const w = selected;
return (
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style={`border-left:1px solid #2d3748;border-right:none;${!collapsed && width ? `width:${width}px;min-width:${width}px;` : ''}`}>
<div class="panel-header">
{!collapsed && <span class="panel-title">Properties</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
+3 -2
View File
@@ -37,9 +37,10 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) {
interface Props {
onDragStart?: (sig: SignalRef) => void;
width?: number;
}
export default function SignalTree({ onDragStart }: Props) {
export default function SignalTree({ onDragStart, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [groups, setGroups] = useState<DsGroup[]>([]);
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
@@ -211,7 +212,7 @@ export default function SignalTree({ onDragStart }: Props) {
}
return (
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
<div class="panel-header">
{!collapsed && <span class="panel-title">Signals</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
+27 -15
View File
@@ -1,22 +1,27 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import LuaEditor from './LuaEditor';
interface NodeParam {
label: string;
key: string;
type: 'number' | 'text';
type: 'number' | 'text' | 'lua';
default: string;
}
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] },
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass Filter', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] },
];
interface PipelineNode {
@@ -160,12 +165,19 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
{paramDefs.map(pd => (
<div key={pd.key} class="wizard-field wizard-field-inline">
<label>{pd.label}</label>
<input
class="prop-input"
type={pd.type === 'number' ? 'number' : 'text'}
value={node.params[pd.key] ?? pd.default}
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)}
/>
{pd.type === 'lua' ? (
<LuaEditor
value={String(node.params[pd.key] ?? pd.default)}
onChange={(v) => setNodeParam(idx, pd.key, v)}
/>
) : (
<input
class="prop-input"
type={pd.type === 'number' ? 'number' : 'text'}
value={node.params[pd.key] ?? pd.default}
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)}
/>
)}
</div>
))}
</div>
+24 -12
View File
@@ -1,5 +1,6 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import LuaEditor from './LuaEditor';
interface Props {
onClose: () => void;
@@ -9,19 +10,23 @@ interface Props {
interface NodeParam {
label: string;
key: string;
type: 'number' | 'text';
type: 'number' | 'text' | 'lua';
default: string;
}
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] },
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass Filter', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] },
];
export default function SyntheticWizard({ onClose, onCreated }: Props) {
@@ -137,9 +142,16 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
{nodeDef.params.map(p => (
<div key={p.key} class="wizard-field">
<label>{p.label}</label>
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
value={getParam(p.key, p.default)}
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
{p.type === 'lua' ? (
<LuaEditor
value={getParam(p.key, p.default)}
onChange={(v) => setParam(p.key, v)}
/>
) : (
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
value={getParam(p.key, p.default)}
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
)}
</div>
))}
+30 -3
View File
@@ -3,12 +3,13 @@ import { useState, useMemo, useEffect } from 'preact/hooks';
import InterfaceList from './InterfaceList';
import Canvas from './Canvas';
import PlotPanel from './PlotPanel';
import type { PlotSignal } from './PlotPanel';
import type { PlotSignal, PlotSignalStyle } from './PlotPanel';
import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml';
import type { Interface, SignalRef } from './lib/types';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
interface Props {
onEdit?: (iface?: Interface) => void;
@@ -34,6 +35,22 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
const [showTimeNav, setShowTimeNav] = useState(false);
const [viewTab, setViewTab] = useState<ViewTab>('hmi');
const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]);
const [leftW, setLeftW] = useState(220);
function startResize(e: MouseEvent) {
e.preventDefault();
const startX = e.clientX;
const startW = leftW;
function onMove(mv: MouseEvent) {
setLeftW(Math.max(140, startW + mv.clientX - startX));
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
@@ -100,7 +117,8 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
const already = plotSignals.some(s => `${s.ref.ds}\0${s.ref.name}` === key);
if (!already) {
const color = PLOT_COLORS[plotSignals.length % PLOT_COLORS.length];
setPlotSignals(prev => [...prev, { ref, color }]);
const style: PlotSignalStyle = { color, lineWidth: 1.5, lineDash: [], markerSize: 5 };
setPlotSignals(prev => [...prev, { ref, style }]);
}
setViewTab('plot');
}
@@ -109,6 +127,12 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
setPlotSignals(prev => prev.filter(s => !(s.ref.ds === ref.ds && s.ref.name === ref.name)));
}
function updateSignalStyle(ref: SignalRef, style: PlotSignalStyle) {
setPlotSignals(prev => prev.map(s =>
s.ref.ds === ref.ds && s.ref.name === ref.name ? { ...s, style } : s
));
}
const isLive = timeRange === null;
return (
@@ -145,6 +169,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
>
📖
</button>
<ZoomControl />
<button
class="btn-edit"
onClick={() => onEdit?.(currentInterface ?? undefined)}
@@ -195,6 +220,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
onLoad={handleLoad}
onEdit={onEdit}
onEditId={handleEditById}
width={leftW}
onSelect={async (id) => {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
@@ -206,6 +232,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
}
}}
/>
<div class="panel-resize-handle" onMouseDown={startResize} />
<div class="view-content-area">
{/* Tab bar */}
@@ -234,7 +261,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
/>
</div>
<div style={`display:${viewTab === 'plot' ? 'contents' : 'none'}`}>
<PlotPanel signals={plotSignals} onRemove={removeFromPlot} />
<PlotPanel signals={plotSignals} onRemove={removeFromPlot} onStyleChange={updateSignalStyle} />
</div>
</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
const LS_KEY = 'uopi:ui-zoom';
const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5];
export function getStoredZoom(): number {
const v = parseFloat(localStorage.getItem(LS_KEY) ?? '1');
return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1;
}
export function applyZoom(z: number): number {
const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z));
localStorage.setItem(LS_KEY, String(clamped));
document.documentElement.style.fontSize = `${Math.round(16 * clamped)}px`;
return clamped;
}
export default function ZoomControl() {
const [zoom, setZoom] = useState(getStoredZoom);
function step(dir: number) {
const idx = STEPS.findIndex(s => s >= zoom - 0.01);
const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir));
setZoom(applyZoom(STEPS[nextIdx]));
}
return (
<div class="zoom-control" title="UI zoom">
<button class="icon-btn zoom-btn" onClick={() => step(-1)} title="Zoom out (smaller UI)">A</button>
<span class="zoom-pct">{Math.round(zoom * 100)}%</span>
<button class="icon-btn zoom-btn" onClick={() => step(1)} title="Zoom in (larger UI)">A+</button>
</div>
);
}
+201 -9
View File
@@ -5,6 +5,10 @@
padding: 0;
}
html {
font-size: clamp(13px, 1.5vh, 18px);
}
body {
background: #0f1117;
color: #e2e8f0;
@@ -52,7 +56,7 @@ body {
align-items: center;
justify-content: space-between;
padding: 0 1rem;
height: 44px;
height: 2.75rem;
background: #1a1f2e;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
@@ -167,6 +171,22 @@ body {
min-height: 0;
}
/* ── Panel resize handle ─────────────────────────────────────────────────── */
.panel-resize-handle {
width: 5px;
flex-shrink: 0;
background: transparent;
cursor: ew-resize;
position: relative;
z-index: 10;
transition: background 0.15s;
}
.panel-resize-handle:hover,
.panel-resize-handle.dragging {
background: #4a9eff55;
}
/* ── EditMode ──────────────────────────────────────────────────────────── */
.edit-mode {
@@ -204,7 +224,7 @@ body {
justify-content: space-between;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border-bottom: 1px solid #2d3748;
min-height: 40px;
min-height: 2.5rem;
flex-shrink: 0;
}
@@ -472,7 +492,6 @@ body {
.gauge-svg {
width: 100%;
flex: 1;
overflow: visible;
}
.gauge-value {
@@ -776,6 +795,12 @@ body {
border-color: #4a9eff;
}
.sv-select {
flex: 1;
width: auto;
cursor: pointer;
}
.sv-current {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
@@ -914,7 +939,7 @@ body {
}
.chart-area {
overflow: hidden;
overflow: visible;
}
/* uPlot dark overrides */
@@ -1301,9 +1326,13 @@ body {
background: #1e2535;
border: 1px solid #2d3748;
border-radius: 8px;
width: 420px;
width: min(600px, 92vw);
min-width: 380px;
min-height: 440px;
max-width: 95vw;
max-height: 90vh;
max-height: 92vh;
resize: both;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
@@ -1374,6 +1403,68 @@ body {
/* ── Synthetic Editor ────────────────────────────────────────────────────── */
/* ── Lua editor ────────────────────────────────────────────────────────────── */
.lua-editor {
position: relative;
border: 1px solid #3d4f6e;
border-radius: 4px;
overflow: hidden;
background: #0a0d14;
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
min-height: 6em;
}
.lua-pre,
.lua-textarea {
position: absolute;
inset: 0;
margin: 0;
padding: 6px 8px;
border: none;
outline: none;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
tab-size: 2;
font: inherit;
box-sizing: border-box;
}
.lua-pre {
color: #e2e8f0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.lua-textarea {
color: transparent;
caret-color: #e2e8f0;
background: transparent;
resize: none;
z-index: 1;
width: 100%;
height: 100%;
}
.lua-textarea:focus {
outline: none;
box-shadow: inset 0 0 0 1px #4a9eff;
}
/* Syntax token colours */
.lua-kw { color: #c792ea; font-weight: 600; }
.lua-str { color: #c3e88d; }
.lua-cmt { color: #546e7a; font-style: italic; }
.lua-num { color: #f78c6c; }
/* wizard is already wide enough and resizable — no extra :has() rule needed */
/* ── Synthetic pipeline ─────────────────────────────────────────────────────── */
.synth-pipeline-node {
background: #1a2234;
border: 1px solid #2d3748;
@@ -2170,7 +2261,7 @@ kbd {
align-items: center;
gap: 2px;
padding: 0 0.5rem;
height: 36px;
height: 2.25rem;
background: #0f1117;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
@@ -2312,7 +2403,7 @@ kbd {
align-items: center;
gap: 0.5rem;
padding: 0 0.75rem;
height: 40px;
height: 2.5rem;
background: #0f1117;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
@@ -2350,7 +2441,11 @@ kbd {
}
.plot-panel-content {
display: contents;
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.plot-panel-legend {
@@ -2394,6 +2489,21 @@ kbd {
white-space: nowrap;
}
.plot-legend-icon-btn {
background: none;
border: none;
color: #475569;
cursor: pointer;
font-size: 0.75rem;
padding: 0 0.15rem;
line-height: 1;
opacity: 0.6;
border-radius: 3px;
}
.plot-legend-icon-btn:hover { opacity: 1; color: #94a3b8; }
.plot-legend-icon-btn.active { opacity: 1; color: #4a9eff; }
/* kept for back-compat — remove button is now .plot-legend-icon-btn */
.plot-legend-rm {
background: none;
border: none;
@@ -2405,6 +2515,60 @@ kbd {
}
.plot-legend-rm:hover { opacity: 1; color: #f87171; }
/* ── Per-signal style editor ────────────────────────────────────────────── */
.plot-style-editor {
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px solid #1e293b;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.plot-style-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.plot-style-label {
font-size: 0.68rem;
color: #64748b;
width: 2.8rem;
flex-shrink: 0;
}
.plot-style-color {
width: 28px;
height: 20px;
border: 1px solid #2d3748;
border-radius: 3px;
padding: 0;
cursor: pointer;
background: none;
}
.plot-style-btns {
display: flex;
gap: 2px;
}
.plot-style-btn {
background: #1e293b;
border: 1px solid #2d3748;
color: #64748b;
cursor: pointer;
font-size: 0.68rem;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.4;
min-width: 1.6rem;
text-align: center;
}
.plot-style-btn:hover { color: #cbd5e1; border-color: #475569; }
.plot-style-btn.active { background: #1e3a5f; border-color: #4a9eff; color: #e2e8f0; }
.plot-stats-tbl {
width: 100%;
border-collapse: collapse;
@@ -2437,3 +2601,31 @@ kbd {
width: 100% !important;
height: 100% !important;
}
/* ── Zoom control ─────────────────────────────────────────────────────────── */
.zoom-control {
display: flex;
align-items: center;
gap: 2px;
}
.zoom-btn {
background: #1e293b;
border: 1px solid #2d3748;
color: #94a3b8;
cursor: pointer;
font-size: 0.75rem;
padding: 2px 7px;
border-radius: 4px;
line-height: 1.4;
}
.zoom-btn:hover { color: #e2e8f0; border-color: #475569; background: #273244; }
.zoom-pct {
font-size: 0.72rem;
color: #64748b;
width: 2.8rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
+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>
);
}