Implemented epics read/write
This commit is contained in:
+9
-1
@@ -3,10 +3,14 @@ import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from './lib/ws';
|
||||
import ViewMode from './ViewMode';
|
||||
import EditMode from './EditMode';
|
||||
import FullscreenMode from './FullscreenMode';
|
||||
import type { Interface } from './lib/types';
|
||||
|
||||
type AppMode = 'view' | 'edit';
|
||||
|
||||
// If URL has ?fs=<id>, render bare fullscreen canvas
|
||||
const fsParam = new URLSearchParams(window.location.search).get('fs');
|
||||
|
||||
export default function App() {
|
||||
const [mode, setMode] = useState<AppMode>('view');
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
@@ -20,9 +24,13 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||
wsClient.connect('/ws');
|
||||
if (!fsParam) wsClient.connect('/ws');
|
||||
}, []);
|
||||
|
||||
if (fsParam) {
|
||||
return <FullscreenMode interfaceId={fsParam} />;
|
||||
}
|
||||
|
||||
function enterEdit(iface?: Interface) {
|
||||
setEditTarget(iface ?? null);
|
||||
setMode('edit');
|
||||
|
||||
+101
-15
@@ -37,6 +37,11 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
link: [140, 50],
|
||||
};
|
||||
|
||||
// Widget types that support multiple signals (signal can be appended)
|
||||
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
|
||||
// Widget types that have exactly one signal (signal can be replaced)
|
||||
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
|
||||
|
||||
interface PickerState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
@@ -46,6 +51,14 @@ interface PickerState {
|
||||
signal: SignalRef | null;
|
||||
}
|
||||
|
||||
interface SignalDropMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
signal: SignalRef;
|
||||
targetWidget: Widget;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
@@ -65,9 +78,9 @@ interface ResizeState {
|
||||
|
||||
interface RubberBand {
|
||||
active: boolean;
|
||||
startX: number; startY: number; // canvas-relative
|
||||
startX: number; startY: number;
|
||||
curX: number; curY: number;
|
||||
clientStartX: number; clientStartY: number; // for scroll adjustment
|
||||
clientStartX: number; clientStartY: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -75,7 +88,7 @@ interface Props {
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[]) => void;
|
||||
onChange: (widgets: Widget[]) => void;
|
||||
snapGrid: number; // 0 = off
|
||||
snapGrid: number;
|
||||
}
|
||||
|
||||
let _widgetSeq = Date.now();
|
||||
@@ -95,7 +108,6 @@ function rectsIntersect(
|
||||
export default function EditCanvas({ iface, selectedIds, onSelect, onChange, snapGrid }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs: always hold current values so event handlers don't go stale
|
||||
const ifaceRef = useRef(iface);
|
||||
ifaceRef.current = iface;
|
||||
const onChangeRef = useRef(onChange);
|
||||
@@ -111,14 +123,14 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
const rubberRef = useRef<RubberBand>({ active: false, startX: 0, startY: 0, curX: 0, curY: 0, clientStartX: 0, clientStartY: 0 });
|
||||
|
||||
// Rubber-band visual state (drives re-renders during drag)
|
||||
const [rubberVis, setRubberVis] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const [picker, setPicker] = useState<PickerState>({
|
||||
visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null,
|
||||
});
|
||||
|
||||
// Set up document-level mouse handlers once
|
||||
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const grid = snapGridRef.current;
|
||||
@@ -213,7 +225,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
function handleCanvasMouseDown(e: MouseEvent) {
|
||||
if ((e.target as Element).closest('.widget-overlay')) return;
|
||||
if ((e.target as Element).closest('.widget-picker-backdrop')) return;
|
||||
// Start rubber-band on empty canvas
|
||||
if ((e.target as Element).closest('.signal-drop-backdrop')) return;
|
||||
const { cx, cy } = getCanvasPos(e);
|
||||
rubberRef.current = { active: true, startX: cx, startY: cy, curX: cx, curY: cy, clientStartX: e.clientX, clientStartY: e.clientY };
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
@@ -232,10 +244,50 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
if (!json) return;
|
||||
let sig: SignalRef;
|
||||
try { sig = JSON.parse(json); } catch { return; }
|
||||
|
||||
const { cx, cy } = getCanvasPos(e);
|
||||
|
||||
// Check if drop lands on an existing widget
|
||||
const hit = iface.widgets.find(w =>
|
||||
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
|
||||
);
|
||||
|
||||
if (hit && (MULTI_SIGNAL_TYPES.has(hit.type) || SINGLE_SIGNAL_TYPES.has(hit.type))) {
|
||||
// Offer add/replace vs create-new
|
||||
setSignalDropMenu({ visible: true, x: e.clientX, y: e.clientY, signal: sig, targetWidget: hit });
|
||||
return;
|
||||
}
|
||||
|
||||
// No existing widget hit — open type picker
|
||||
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
|
||||
}
|
||||
|
||||
function handleAddToWidget(widget: Widget, sig: SignalRef) {
|
||||
setSignalDropMenu(null);
|
||||
let updated: Widget;
|
||||
if (MULTI_SIGNAL_TYPES.has(widget.type)) {
|
||||
// Append signal (avoid duplicates)
|
||||
const already = widget.signals.some(s => s.ds === sig.ds && s.name === sig.name);
|
||||
if (already) return;
|
||||
updated = { ...widget, signals: [...widget.signals, sig] };
|
||||
} else {
|
||||
// Replace first signal
|
||||
updated = { ...widget, signals: [sig, ...widget.signals.slice(1)] };
|
||||
}
|
||||
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w));
|
||||
onSelect([updated.id]);
|
||||
}
|
||||
|
||||
function handleSignalDropCreateNew(sig: SignalRef, cx: number, cy: number) {
|
||||
setSignalDropMenu(null);
|
||||
// We don't have canvas coords in the menu state, approximate from screen coords
|
||||
const el = containerRef.current;
|
||||
const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
const canvasX = cx - rect.left + (el?.scrollLeft ?? 0);
|
||||
const canvasY = cy - rect.top + (el?.scrollTop ?? 0);
|
||||
setPicker({ visible: true, x: cx, y: cy, canvasX, canvasY, signal: sig });
|
||||
}
|
||||
|
||||
function handlePick(type: string) {
|
||||
if (!picker.signal) return;
|
||||
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||
@@ -255,10 +307,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
setPicker(p => ({ ...p, visible: false }));
|
||||
}
|
||||
|
||||
// Called from EditMode when inserting a static widget (no signal)
|
||||
// This is triggered via the parent placing a widget directly.
|
||||
// (handled via onInsert prop in extended usage)
|
||||
|
||||
function handleOverlayMouseDown(e: MouseEvent, widget: Widget) {
|
||||
e.stopPropagation();
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
@@ -274,7 +322,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
}
|
||||
onSelect(nextIds);
|
||||
|
||||
// Don't start drag on Ctrl+click deselect
|
||||
if (ctrl && isSelected) return;
|
||||
|
||||
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
|
||||
@@ -317,7 +364,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
>
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
|
||||
{/* Live widget previews */}
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
@@ -330,7 +376,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Interaction overlays */}
|
||||
{iface.widgets.map(widget => {
|
||||
const isSelected = selectedIds.includes(widget.id);
|
||||
return (
|
||||
@@ -361,7 +406,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rubber-band selection rectangle */}
|
||||
{rubberVis && (
|
||||
<div
|
||||
class="rubber-band"
|
||||
@@ -378,6 +422,48 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Signal drop menu: appears when dropping onto an existing widget */}
|
||||
{signalDropMenu && (
|
||||
<div class="signal-drop-backdrop" onClick={() => setSignalDropMenu(null)}>
|
||||
<div
|
||||
class="signal-drop-menu"
|
||||
style={`left:${signalDropMenu.x}px;top:${signalDropMenu.y}px;`}
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div class="signal-drop-header">
|
||||
Drop signal <strong>{signalDropMenu.signal.name}</strong>
|
||||
</div>
|
||||
{MULTI_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
|
||||
<button
|
||||
class="ctx-item"
|
||||
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
|
||||
>
|
||||
+ Add signal to this {signalDropMenu.targetWidget.type}
|
||||
</button>
|
||||
)}
|
||||
{SINGLE_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
|
||||
<button
|
||||
class="ctx-item"
|
||||
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
|
||||
>
|
||||
↺ Replace signal in this {signalDropMenu.targetWidget.type}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class="ctx-item"
|
||||
onClick={() => handleSignalDropCreateNew(
|
||||
signalDropMenu.signal,
|
||||
signalDropMenu.x,
|
||||
signalDropMenu.y,
|
||||
)}
|
||||
>
|
||||
+ Create new widget
|
||||
</button>
|
||||
<button class="ctx-item ctx-cancel" onClick={() => setSignalDropMenu(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import type { Interface } from './lib/types';
|
||||
import Canvas from './Canvas';
|
||||
|
||||
interface Props {
|
||||
interfaceId: string;
|
||||
}
|
||||
|
||||
export default function FullscreenMode({ interfaceId }: Props) {
|
||||
const [iface, setIface] = useState<Interface | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
wsClient.connect('/ws');
|
||||
fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.text();
|
||||
})
|
||||
.then(xml => setIface(parseInterface(xml)))
|
||||
.catch(err => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [interfaceId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div class="fs-error">
|
||||
<p>Failed to load interface: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="fs-view">
|
||||
<Canvas iface={iface} onNavigate={async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setIface(parseInterface(await res.text()));
|
||||
} catch { /* ignore */ }
|
||||
}} timeRange={null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,10 @@ interface Props {
|
||||
onLoad: (xml: string) => void;
|
||||
onSelect?: (id: string) => void;
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
onEditId?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -60,6 +61,10 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
function handleFullscreen(id: string) {
|
||||
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
|
||||
}
|
||||
|
||||
return (
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<div class="panel-header">
|
||||
@@ -100,7 +105,8 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
|
||||
{iface.name || iface.id}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="Edit" onClick={() => onEdit?.()}>✎</button>
|
||||
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}>✎</button>
|
||||
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}>⛶</button>
|
||||
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}>⎘</button>
|
||||
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}>✕</button>
|
||||
</div>
|
||||
|
||||
+175
-53
@@ -4,7 +4,7 @@ import type { Widget, Interface } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
selected: Widget | null;
|
||||
multiCount: number; // >0 when multiple widgets are selected
|
||||
multiCount: number;
|
||||
iface: Interface;
|
||||
onChange: (updated: Widget) => void;
|
||||
onIfaceChange: (updated: Interface) => void;
|
||||
@@ -21,6 +21,10 @@ function Field({ label, children }: { label: string; children: any }) {
|
||||
|
||||
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [local, setLocal] = useState(value);
|
||||
// Sync when external value changes (e.g. different widget selected)
|
||||
if (local !== value && document.activeElement?.tagName !== 'INPUT') {
|
||||
setLocal(value);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
class="prop-input"
|
||||
@@ -31,6 +35,13 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
|
||||
);
|
||||
}
|
||||
|
||||
// Widgets that show units from signal metadata (can be overridden)
|
||||
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
||||
// Widgets with a signal-based label (can be overridden)
|
||||
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
|
||||
// Widgets with multiple signals
|
||||
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
||||
|
||||
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
@@ -44,6 +55,14 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
onChange({ ...selected, [key]: value } as Widget);
|
||||
}
|
||||
|
||||
function removeSignal(idx: number) {
|
||||
if (!selected) return;
|
||||
const signals = selected.signals.filter((_, i) => i !== idx);
|
||||
onChange({ ...selected, signals });
|
||||
}
|
||||
|
||||
const w = selected;
|
||||
|
||||
return (
|
||||
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
|
||||
<div class="panel-header">
|
||||
@@ -55,7 +74,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
|
||||
{!collapsed && (
|
||||
<div class="props-body">
|
||||
{/* Interface-level properties (always shown) */}
|
||||
{/* Canvas-level properties */}
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">Canvas</div>
|
||||
<Field label="Name">
|
||||
@@ -84,117 +103,220 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
{w && (
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">{selected.type}</div>
|
||||
<div class="props-section-title">{w.type}</div>
|
||||
|
||||
{/* Position / size */}
|
||||
<Field label="X">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.x}
|
||||
<input class="prop-input prop-input-num" type="number" value={w.x}
|
||||
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
||||
</Field>
|
||||
<Field label="Y">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.y}
|
||||
<input class="prop-input prop-input-num" type="number" value={w.y}
|
||||
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
||||
</Field>
|
||||
<Field label="Width">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.w} min={10}
|
||||
<input class="prop-input prop-input-num" type="number" value={w.w} min={10}
|
||||
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
||||
</Field>
|
||||
<Field label="Height">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.h} min={10}
|
||||
<input class="prop-input prop-input-num" type="number" value={w.h} min={10}
|
||||
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
||||
</Field>
|
||||
|
||||
{/* Signal */}
|
||||
{selected.signals.length > 0 && (
|
||||
{/* Signals */}
|
||||
{MULTI_SIGNAL_WIDGETS.has(w.type) ? (
|
||||
<Field label="Signals">
|
||||
<div class="prop-signals-list">
|
||||
{w.signals.length === 0 && <span class="hint">No signals</span>}
|
||||
{w.signals.map((s, i) => (
|
||||
<div key={i} class="prop-signal-row">
|
||||
<span class="prop-sig">{s.ds}:{s.name}</span>
|
||||
<button
|
||||
class="icon-btn prop-sig-remove"
|
||||
title="Remove signal"
|
||||
onClick={() => removeSignal(i)}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
) : w.signals.length > 0 ? (
|
||||
<Field label="Signal">
|
||||
<span class="prop-sig">{selected.signals[0].ds}:{selected.signals[0].name}</span>
|
||||
<span class="prop-sig">{w.signals[0].ds}:{w.signals[0].name}</span>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
{/* ── Common label/unit overrides (issue #8) ──────────────────── */}
|
||||
|
||||
{/* Label: button and textlabel use it as display text;
|
||||
signal widgets use it as header label */}
|
||||
{w.type !== 'image' && w.type !== 'link' && (
|
||||
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
||||
<TextInput
|
||||
value={w.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
{LABEL_WIDGETS.has(w.type) && (
|
||||
<span class="prop-hint">Empty = signal name</span>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Common options */}
|
||||
<Field label="Label">
|
||||
<TextInput
|
||||
value={selected.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
</Field>
|
||||
{/* Unit override for signal widgets */}
|
||||
{UNIT_WIDGETS.has(w.type) && (
|
||||
<Field label="Unit override">
|
||||
<TextInput
|
||||
value={w.options['unit'] ?? ''}
|
||||
onCommit={(v) => setOpt('unit', v)}
|
||||
/>
|
||||
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Type-specific options */}
|
||||
{(selected.type === 'gauge' || selected.type === 'barh' || selected.type === 'barv') && (
|
||||
{/* ── Type-specific options ──────────────────────────────────── */}
|
||||
|
||||
{(w.type === 'gauge' || w.type === 'barh' || w.type === 'barv') && (
|
||||
<div>
|
||||
<Field label="Min">
|
||||
<TextInput value={selected.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
|
||||
<TextInput value={w.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
|
||||
</Field>
|
||||
<Field label="Max">
|
||||
<TextInput value={selected.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
|
||||
<TextInput value={w.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'led' && (
|
||||
{w.type === 'led' && (
|
||||
<Field label="Condition">
|
||||
<TextInput value={selected.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
|
||||
<TextInput value={w.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selected.type === 'setvalue' && (
|
||||
<Field label="Min">
|
||||
<TextInput value={selected.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
|
||||
</Field>
|
||||
{w.type === 'setvalue' && (
|
||||
<div>
|
||||
<Field label="Min">
|
||||
<TextInput value={w.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
|
||||
</Field>
|
||||
<Field label="Confirm dialog">
|
||||
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
|
||||
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'plot' && (
|
||||
<Field label="Plot type">
|
||||
<select
|
||||
class="prop-select"
|
||||
value={selected.options['plotType'] ?? 'timeseries'}
|
||||
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="timeseries">Time-series</option>
|
||||
<option value="histogram">Histogram</option>
|
||||
<option value="bar">Bar chart</option>
|
||||
<option value="fft">FFT</option>
|
||||
<option value="waterfall">Waterfall</option>
|
||||
<option value="logic">Logic analyser</option>
|
||||
</select>
|
||||
</Field>
|
||||
{/* Button options (issue #2) */}
|
||||
{w.type === 'button' && (
|
||||
<div>
|
||||
<Field label="Send value">
|
||||
<TextInput value={w.options['value'] ?? '1'} onCommit={(v) => setOpt('value', v)} />
|
||||
</Field>
|
||||
<Field label="Mode">
|
||||
<select class="prop-select" value={w.options['mode'] ?? 'oneshot'}
|
||||
onChange={(e) => setOpt('mode', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="oneshot">One-shot (set once)</option>
|
||||
<option value="setReset">Set-reset (auto-revert)</option>
|
||||
<option value="persistent">Persistent (toggle / monitor)</option>
|
||||
</select>
|
||||
</Field>
|
||||
{(w.options['mode'] === 'setReset' || w.options['mode'] === 'persistent') && (
|
||||
<Field label="Reset value">
|
||||
<TextInput value={w.options['resetValue'] ?? '0'} onCommit={(v) => setOpt('resetValue', v)} />
|
||||
</Field>
|
||||
)}
|
||||
{w.options['mode'] === 'setReset' && (
|
||||
<Field label="Reset after (s)">
|
||||
<TextInput value={w.options['resetTime'] ?? '1'} onCommit={(v) => setOpt('resetTime', v)} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Confirm dialog">
|
||||
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
|
||||
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'textlabel' && (
|
||||
{/* Plot options */}
|
||||
{w.type === 'plot' && (
|
||||
<div>
|
||||
<Field label="Plot type">
|
||||
<select
|
||||
class="prop-select"
|
||||
value={w.options['plotType'] ?? 'timeseries'}
|
||||
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="timeseries">Time-series</option>
|
||||
<option value="histogram">Histogram</option>
|
||||
<option value="bar">Bar chart</option>
|
||||
<option value="fft">FFT</option>
|
||||
<option value="waterfall">Waterfall</option>
|
||||
<option value="logic">Logic analyser</option>
|
||||
</select>
|
||||
</Field>
|
||||
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
|
||||
<Field label="Time window (s)">
|
||||
<TextInput value={w.options['timeWindow'] ?? '60'} onCommit={(v) => setOpt('timeWindow', v)} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Y min">
|
||||
<TextInput value={w.options['yMin'] ?? 'auto'} onCommit={(v) => setOpt('yMin', v)} />
|
||||
</Field>
|
||||
<Field label="Y max">
|
||||
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
|
||||
</Field>
|
||||
<Field label="Legend">
|
||||
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
|
||||
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="bottom">Bottom</option>
|
||||
<option value="top">Top</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{w.type === 'textlabel' && (
|
||||
<div>
|
||||
<Field label="Font size">
|
||||
<TextInput value={selected.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
|
||||
<TextInput value={w.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
|
||||
</Field>
|
||||
<Field label="Color">
|
||||
<TextInput value={selected.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
|
||||
<TextInput value={w.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'image' && (
|
||||
{w.type === 'image' && (
|
||||
<Field label="URL">
|
||||
<TextInput value={selected.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
|
||||
<TextInput value={w.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selected.type === 'link' && (
|
||||
{w.type === 'link' && (
|
||||
<div>
|
||||
<Field label="Target">
|
||||
<TextInput value={selected.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
|
||||
<TextInput value={w.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
|
||||
</Field>
|
||||
<Field label="Label">
|
||||
<TextInput value={selected.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selected && multiCount > 1 && (
|
||||
{!w && multiCount > 1 && (
|
||||
<p class="hint" style="padding:0.75rem;">{multiCount} widgets selected.<br/>Use align/distribute tools in the toolbar.</p>
|
||||
)}
|
||||
|
||||
{!selected && multiCount === 0 && (
|
||||
{!w && multiCount === 0 && (
|
||||
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+58
-36
@@ -1,11 +1,10 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
import { useState, useMemo, useEffect } from 'preact/hooks';
|
||||
import InterfaceList from './InterfaceList';
|
||||
import Canvas from './Canvas';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import type { Interface } from './lib/types';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
|
||||
@@ -25,6 +24,7 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
|
||||
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
||||
|
||||
@@ -52,13 +52,24 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const xml = await res.text();
|
||||
handleLoad(xml);
|
||||
handleLoad(await res.text());
|
||||
} catch (err) {
|
||||
setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load interface by ID and pass parsed object to onEdit */
|
||||
async function handleEditById(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const iface = parseInterface(await res.text());
|
||||
onEdit?.(iface);
|
||||
} catch (err) {
|
||||
setParseError(`Failed to load interface for editing: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadHistory() {
|
||||
if (!startInput || !endInput) return;
|
||||
const start = new Date(startInput).toISOString();
|
||||
@@ -82,38 +93,6 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{/* Time range picker */}
|
||||
<div class="time-nav">
|
||||
<button
|
||||
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
|
||||
title="Switch to live data"
|
||||
onClick={handleGoLive}
|
||||
>
|
||||
● Live
|
||||
</button>
|
||||
<input
|
||||
class="time-input"
|
||||
type="datetime-local"
|
||||
value={startInput}
|
||||
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
|
||||
title="History start"
|
||||
/>
|
||||
<span class="time-sep">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="datetime-local"
|
||||
value={endInput}
|
||||
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
|
||||
title="History end"
|
||||
/>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
title="Load historical data for this time range"
|
||||
onClick={handleLoadHistory}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
{parseError && (
|
||||
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
||||
)}
|
||||
@@ -123,6 +102,13 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
<span class="status-dot"></span>
|
||||
{wsStatus}
|
||||
</div>
|
||||
<button
|
||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||
title="Toggle historical time navigation"
|
||||
onClick={() => setShowTimeNav(v => !v)}
|
||||
>
|
||||
⏱ History
|
||||
</button>
|
||||
<ContextualHelp mode="view" onOpenManual={openHelp} />
|
||||
<button
|
||||
class="icon-btn help-manual-btn"
|
||||
@@ -141,10 +127,46 @@ export default function ViewMode({ onEdit }: Props) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Historical time range bar — hidden by default */}
|
||||
{showTimeNav && (
|
||||
<div class="time-nav-bar">
|
||||
<button
|
||||
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
|
||||
title="Switch to live data"
|
||||
onClick={handleGoLive}
|
||||
>
|
||||
● Live
|
||||
</button>
|
||||
<input
|
||||
class="time-input"
|
||||
type="datetime-local"
|
||||
value={startInput}
|
||||
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
|
||||
title="History start"
|
||||
/>
|
||||
<span class="time-sep">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="datetime-local"
|
||||
value={endInput}
|
||||
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
|
||||
title="History end"
|
||||
/>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
title="Load historical data for this time range"
|
||||
onClick={handleLoadHistory}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<InterfaceList
|
||||
onLoad={handleLoad}
|
||||
onEdit={onEdit}
|
||||
onEditId={handleEditById}
|
||||
onSelect={async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
|
||||
@@ -259,6 +259,7 @@ class WsClient {
|
||||
}
|
||||
|
||||
write(ref: SignalRef, value: any): void {
|
||||
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
|
||||
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
|
||||
}
|
||||
}
|
||||
|
||||
+134
-27
@@ -994,33 +994,7 @@ body {
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
/* ── Time range / history navigation ──────────────────────────────────────── */
|
||||
|
||||
.time-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
font-size: 0.75rem;
|
||||
background: #1a2236;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 4px;
|
||||
color: #cbd5e1;
|
||||
padding: 0.2rem 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-input:focus { outline: none; border-color: #63b3ed; }
|
||||
|
||||
/* Colour-scheme hint for the browser's native date-time picker */
|
||||
.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); }
|
||||
|
||||
.time-sep {
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
/* time-nav styles are in the time-nav-bar section at the bottom of this file */
|
||||
|
||||
/* Overlay shown inside a plot widget while history is loading or unavailable */
|
||||
.hist-overlay {
|
||||
@@ -1966,3 +1940,136 @@ kbd {
|
||||
}
|
||||
.ctx-help-link:hover { text-decoration: underline; }
|
||||
|
||||
|
||||
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
|
||||
|
||||
.time-nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 1rem;
|
||||
background: #161b27;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
font-size: 0.75rem;
|
||||
background: #1a2236;
|
||||
border: 1px solid #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
cursor: pointer;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.time-input:focus { outline: none; border-color: #63b3ed; }
|
||||
.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); }
|
||||
|
||||
.time-sep {
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── Fullscreen mode ──────────────────────────────────────────────────────── */
|
||||
|
||||
.fs-view {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
background: #0f1117;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.fs-error {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #f87171;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ── Signal drop menu (drop onto existing widget) ─────────────────────────── */
|
||||
|
||||
.signal-drop-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9500;
|
||||
}
|
||||
|
||||
.signal-drop-menu {
|
||||
position: fixed;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
|
||||
min-width: 220px;
|
||||
z-index: 9600;
|
||||
overflow: hidden;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.signal-drop-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
.signal-drop-header strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.ctx-cancel {
|
||||
color: #6b7280;
|
||||
border-top: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
/* ── Button widget: pressed / mode hint ──────────────────────────────────── */
|
||||
|
||||
.btn-pressed {
|
||||
background: #1e40af !important;
|
||||
border-color: #3b82f6 !important;
|
||||
color: #93c5fd !important;
|
||||
}
|
||||
|
||||
.btn-mode-hint {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
/* ── PropertiesPane: signals list + hint text ─────────────────────────────── */
|
||||
|
||||
.prop-signals-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prop-signal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.prop-sig-remove {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.prop-sig-remove:hover { opacity: 1; color: #f87171; }
|
||||
|
||||
.prop-hint {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
color: #475569;
|
||||
margin-top: 0.15rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function BarH({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function BarV({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
|
||||
+50
-10
@@ -1,24 +1,59 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Button({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const label = widget.options['label'] ?? 'Button';
|
||||
const valueOpt = widget.options['value'] ?? '1';
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
const label = widget.options['label'] ?? 'Button';
|
||||
const sendValue = widget.options['value'] ?? '1';
|
||||
const resetValue = widget.options['resetValue'] ?? '0';
|
||||
// mode: oneshot | setReset | persistent
|
||||
const mode = widget.options['mode'] ?? 'oneshot';
|
||||
const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1');
|
||||
const confirmOpt = widget.options['confirm'] === 'true';
|
||||
|
||||
const [signalVal, setSignalVal] = useState<any>(null);
|
||||
|
||||
// Subscribe to signal only for persistent mode (to track whether it is active)
|
||||
useEffect(() => {
|
||||
if (!sigRef || mode !== 'persistent') return;
|
||||
return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value));
|
||||
}, [sigRef?.ds, sigRef?.name, mode]);
|
||||
|
||||
const parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue);
|
||||
const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue);
|
||||
|
||||
// Persistent mode: button appears pressed while signal value matches sendValue
|
||||
const isPressed = mode === 'persistent'
|
||||
&& signalVal !== null
|
||||
&& String(signalVal) === String(parsedSend);
|
||||
|
||||
function doWrite(val: any) {
|
||||
if (!sigRef) return;
|
||||
wsClient.write(sigRef, val);
|
||||
}
|
||||
|
||||
function execute() {
|
||||
if (mode === 'setReset') {
|
||||
doWrite(parsedSend);
|
||||
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
|
||||
} else if (mode === 'persistent') {
|
||||
doWrite(isPressed ? parsedReset : parsedSend);
|
||||
} else {
|
||||
doWrite(parsedSend);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!sigRef) return;
|
||||
const parsed = parseFloat(valueOpt);
|
||||
const val = isNaN(parsed) ? valueOpt : parsed;
|
||||
const doWrite = () => wsClient.write(sigRef, val);
|
||||
if (confirm) {
|
||||
if (window.confirm(`Send ${label}?`)) doWrite();
|
||||
if (confirmOpt) {
|
||||
if (window.confirm(`Send ${label}?`)) execute();
|
||||
} else {
|
||||
doWrite();
|
||||
execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +63,12 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button class="btn" onClick={handleClick}>{label}</button>
|
||||
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}>
|
||||
{label}
|
||||
{mode === 'setReset' && (
|
||||
<span class="btn-mode-hint">{resetTimeSec}s</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,9 @@ export default function Gauge({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
|
||||
|
||||
@@ -64,15 +64,15 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
|
||||
|
||||
// Re-render chart when switching between live and historical mode.
|
||||
// We stringify timeRange so the effect dependency is a stable primitive.
|
||||
// Stable primitive deps so the effect re-runs when plotType or signals change.
|
||||
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
|
||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||
const signalsKey = widget.signals.map(s => `${s.ds}:${s.name}${s.color ?? ''}`).join(',');
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const signals = widget.signals;
|
||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
|
||||
const yMin = widget.options['yMin'];
|
||||
const yMax = widget.options['yMax'];
|
||||
@@ -329,7 +329,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
};
|
||||
}, [widget.id, timeRangeKey]);
|
||||
}, [widget.id, timeRangeKey, plotType, signalsKey]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -30,10 +30,11 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
const isNumeric = meta ? meta.type !== 'TypeString' : true;
|
||||
const isNumeric = meta ? meta.type !== 'string' : true;
|
||||
const quality = sv.quality;
|
||||
|
||||
function displayValue(): string {
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function TextView({ widget, onContextMenu }: Props) {
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const label = widget.options['label'] || sigRef?.name || '';
|
||||
const rawUnit = widget.options['unit'];
|
||||
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
|
||||
const quality = sv.quality;
|
||||
|
||||
function displayValue(): string {
|
||||
|
||||
Reference in New Issue
Block a user