Improved UI
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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
@@ -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 (1–8)', 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
@@ -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 (1–8)', 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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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