Improved plot view
This commit is contained in:
@@ -41,28 +41,31 @@
|
|||||||
- [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`)
|
- [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`)
|
||||||
- [ ] Improve UX:
|
- [ ] Improve UX:
|
||||||
- [ ] Synthetic editor:
|
- [ ] Synthetic editor:
|
||||||
- [ ] color code the node link by type
|
- [x] color code the node link by type
|
||||||
- [ ] edges color depends on type (e.g. float) including arrays
|
- [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple)
|
||||||
- [ ] input / outputs are color coded (if can accept many, gray, otherwise specific color, can be dynamic)
|
- [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either)
|
||||||
- [ ] can not connect input / output that are not compatible
|
- [x] can not connect input / output that are not compatible (definite scalar↔array mismatch blocked; unknown/any always allowed)
|
||||||
- [x] hover on a block in error should show the reason
|
- [x] hover on a block in error should show the reason
|
||||||
- [x] add proper array functionality
|
- [x] add proper array functionality
|
||||||
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
|
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
|
||||||
- [ ] Panels:
|
- [ ] Panels:
|
||||||
- [x] in view mode the widgets should have no border/bg but blend with background
|
- [x] in view mode the widgets should have no border/bg but blend with background
|
||||||
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
|
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
|
||||||
|
- [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog)
|
||||||
|
- [ ] table widget
|
||||||
|
- [ ] other industrial hmi widgets
|
||||||
- [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc
|
- [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc
|
||||||
- [ ] plot pane:
|
- [ ] plot pane:
|
||||||
- [ ] add toolbar
|
- [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`)
|
||||||
- [ ] add time window selector to toolbar
|
- [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types)
|
||||||
- [ ] plot x-axis synchronised (option to unlink in toolbar)
|
- [x] plot x-axis synchronised (shared uPlot cursor crosshair across all linked timeseries plots + zoom/pan range sync in historical mode; per-plot 🔗 link/unlink toolbar toggle; `web/src/lib/plotSync.ts`)
|
||||||
- [ ] cursors and measuraments
|
- [x] cursors and measuraments (📏 measure mode: click cursor A then B → on-canvas A/B lines + readout panel with Δt/rate and per-signal value@A→value@B and Δ)
|
||||||
- [ ] pause/resume
|
- [x] pause/resume (local toolbar pause, OR'd with panel-logic pause; buffers persist across plot-type/window changes)
|
||||||
- [ ] save screenshot
|
- [x] save screenshot (PNG export — uPlot canvas / ECharts getDataURL)
|
||||||
- [ ] clean ui:
|
- [x] clean ui:
|
||||||
- create small statusbar where connection widget and other status related info will be placed
|
- [x] create small statusbar where connection widget and other status related info will be placed (bottom `.statusbar`: connection chip + current panel name + history indicator)
|
||||||
- group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar
|
- [x] group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar (⋯ Tools ▾ dropdown)
|
||||||
- make the toolbar as clean as possible
|
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
|
||||||
- [ ] Logic editor:
|
- [ ] Logic editor:
|
||||||
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
|
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||||
- array functions should work with new local array
|
- array functions should work with new local array
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import BarV from './widgets/BarV';
|
|||||||
import Led from './widgets/Led';
|
import Led from './widgets/Led';
|
||||||
import MultiLed from './widgets/MultiLed';
|
import MultiLed from './widgets/MultiLed';
|
||||||
import SetValue from './widgets/SetValue';
|
import SetValue from './widgets/SetValue';
|
||||||
|
import Toggle from './widgets/Toggle';
|
||||||
import Button from './widgets/Button';
|
import Button from './widgets/Button';
|
||||||
import PlotWidget from './widgets/PlotWidget';
|
import PlotWidget from './widgets/PlotWidget';
|
||||||
import ImageWidget from './widgets/ImageWidget';
|
import ImageWidget from './widgets/ImageWidget';
|
||||||
@@ -31,6 +32,7 @@ const COMPONENTS: Record<string, any> = {
|
|||||||
led: Led,
|
led: Led,
|
||||||
multiled: MultiLed,
|
multiled: MultiLed,
|
||||||
setvalue: SetValue,
|
setvalue: SetValue,
|
||||||
|
toggle: Toggle,
|
||||||
button: Button,
|
button: Button,
|
||||||
plot: PlotWidget,
|
plot: PlotWidget,
|
||||||
image: ImageWidget,
|
image: ImageWidget,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import BarV from './widgets/BarV';
|
|||||||
import Led from './widgets/Led';
|
import Led from './widgets/Led';
|
||||||
import MultiLed from './widgets/MultiLed';
|
import MultiLed from './widgets/MultiLed';
|
||||||
import SetValue from './widgets/SetValue';
|
import SetValue from './widgets/SetValue';
|
||||||
|
import Toggle from './widgets/Toggle';
|
||||||
import Button from './widgets/Button';
|
import Button from './widgets/Button';
|
||||||
import PlotWidget from './widgets/PlotWidget';
|
import PlotWidget from './widgets/PlotWidget';
|
||||||
import ImageWidget from './widgets/ImageWidget';
|
import ImageWidget from './widgets/ImageWidget';
|
||||||
@@ -18,7 +19,7 @@ import WidgetTypePicker from './WidgetTypePicker';
|
|||||||
const COMPONENTS: Record<string, any> = {
|
const COMPONENTS: Record<string, any> = {
|
||||||
textview: TextView, textlabel: TextLabel, gauge: Gauge,
|
textview: TextView, textlabel: TextLabel, gauge: Gauge,
|
||||||
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
|
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
|
||||||
setvalue: SetValue, button: Button, plot: PlotWidget,
|
setvalue: SetValue, toggle: Toggle, button: Button, plot: PlotWidget,
|
||||||
image: ImageWidget, link: LinkWidget,
|
image: ImageWidget, link: LinkWidget,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
|||||||
led: [60, 60],
|
led: [60, 60],
|
||||||
multiled: [200, 80],
|
multiled: [200, 80],
|
||||||
setvalue: [200, 60],
|
setvalue: [200, 60],
|
||||||
|
toggle: [140, 50],
|
||||||
button: [120, 50],
|
button: [120, 50],
|
||||||
plot: [400, 200],
|
plot: [400, 200],
|
||||||
image: [200, 150],
|
image: [200, 150],
|
||||||
@@ -41,7 +43,7 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
|||||||
// Widget types that support multiple signals (signal can be appended)
|
// Widget types that support multiple signals (signal can be appended)
|
||||||
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
|
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
|
||||||
// Widget types that have exactly one signal (signal can be replaced)
|
// Widget types that have exactly one signal (signal can be replaced)
|
||||||
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
|
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'toggle', 'button']);
|
||||||
|
|
||||||
interface PickerState {
|
interface PickerState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
|||||||
// Widgets where the user can set a numeric format string
|
// Widgets where the user can set a numeric format string
|
||||||
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
|
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
|
||||||
// Widgets with a signal-based label (can be overridden)
|
// Widgets with a signal-based label (can be overridden)
|
||||||
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
|
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']);
|
||||||
// Widgets with multiple signals
|
// Widgets with multiple signals
|
||||||
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
||||||
|
|
||||||
@@ -247,6 +247,30 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{w.type === 'toggle' && (
|
||||||
|
<div>
|
||||||
|
<Field label="On value">
|
||||||
|
<TextInput value={w.options['onValue'] ?? '1'} onCommit={(v) => setOpt('onValue', v)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Off value">
|
||||||
|
<TextInput value={w.options['offValue'] ?? '0'} onCommit={(v) => setOpt('offValue', v)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="On label">
|
||||||
|
<TextInput value={w.options['onLabel'] ?? ''} onCommit={(v) => setOpt('onLabel', v)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Off label">
|
||||||
|
<TextInput value={w.options['offLabel'] ?? ''} onCommit={(v) => setOpt('offLabel', 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Button options (issue #2) */}
|
{/* Button options (issue #2) */}
|
||||||
{w.type === 'button' && (
|
{w.type === 'button' && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from 'preact/hooks';
|
|||||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
import LuaEditor from './LuaEditor';
|
import LuaEditor from './LuaEditor';
|
||||||
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
||||||
import { inferNodeTypes, SynthType } from './lib/synthTypes';
|
import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes';
|
||||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||||
|
|
||||||
interface DataSource { name: string; }
|
interface DataSource { name: string; }
|
||||||
@@ -542,6 +542,14 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const fn = graph.nodes.find(n => n.id === from);
|
const fn = graph.nodes.find(n => n.id === from);
|
||||||
const tn = graph.nodes.find(n => n.id === to);
|
const tn = graph.nodes.find(n => n.id === to);
|
||||||
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
|
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
|
||||||
|
// Reject a definitely type-incompatible link (e.g. a scalar into an array
|
||||||
|
// reduction). 'unknown'/'any' ports never block — runtime typing is final.
|
||||||
|
const { types } = inferNodeTypes(compile(graph), sourceSynthType);
|
||||||
|
const accept = tn.kind === 'op' ? portAccept(tn.op ?? '') : 'any';
|
||||||
|
if (!typesCompatible(types.get(from) ?? 'unknown', accept)) {
|
||||||
|
setError(`Incompatible connection: ${accept} port can't take a ${types.get(from)} value`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// One wire per input port: replace any existing wire on that port.
|
// One wire per input port: replace any existing wire on that port.
|
||||||
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
|
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
|
||||||
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
|
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
|
||||||
@@ -754,6 +762,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Port colour class. Output ports take the node's own inferred output type;
|
||||||
|
// input ports take what the op accepts (gray when it accepts either type).
|
||||||
|
function portTypeClass(t: SynthType | 'any'): string {
|
||||||
|
if (t === 'array') return ' flow-port-type-array';
|
||||||
|
if (t === 'scalar') return ' flow-port-type-scalar';
|
||||||
|
return ' flow-port-type-any';
|
||||||
|
}
|
||||||
|
function inPortTypeClass(n: GNode): string {
|
||||||
|
return portTypeClass(n.kind === 'op' ? portAccept(n.op ?? '') : 'any');
|
||||||
|
}
|
||||||
|
function outPortTypeClass(n: GNode): string {
|
||||||
|
return portTypeClass(nodeTypes.get(n.id) ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
function nodeClass(n: GNode): string {
|
function nodeClass(n: GNode): string {
|
||||||
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
||||||
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
|
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
|
||||||
@@ -846,7 +868,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const label = portLabel(node, port);
|
const label = portLabel(node, port);
|
||||||
return (
|
return (
|
||||||
<Fragment key={port}>
|
<Fragment key={port}>
|
||||||
<div class="flow-port flow-port-in" title={label || `Input ${port + 1}`}
|
<div class={`flow-port flow-port-in${inPortTypeClass(node)}`} title={label || `Input ${port + 1}`}
|
||||||
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
|
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
|
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
|
||||||
@@ -858,7 +880,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{hasOutput(node.kind) && (
|
{hasOutput(node.kind) && (
|
||||||
<div class="flow-port flow-port-out" title="Output"
|
<div class={`flow-port flow-port-out${outPortTypeClass(node)}`} title="Output"
|
||||||
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
||||||
onMouseDown={(e) => startWire(e, node)} />
|
onMouseDown={(e) => startWire(e, node)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+54
-34
@@ -36,10 +36,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
const [showControlLogic, setShowControlLogic] = useState(false);
|
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||||
const [showAudit, setShowAudit] = useState(false);
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [toolsOpen, setToolsOpen] = useState(false);
|
||||||
const [leftW, setLeftW] = useState(220);
|
const [leftW, setLeftW] = useState(220);
|
||||||
const [listCollapsed, setListCollapsed] = useState(false);
|
const [listCollapsed, setListCollapsed] = useState(false);
|
||||||
const me = useAuth();
|
const me = useAuth();
|
||||||
const writable = canWrite(me.level);
|
const writable = canWrite(me.level);
|
||||||
|
// Advanced tools collapsed into the Tools dropdown (shown only if ≥1 available).
|
||||||
|
const hasTools = (writable && me.canEditLogic) || me.canViewAudit || writable;
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
function startResize(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -69,6 +72,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Close the Tools dropdown on any outside click (the dropdown itself stops
|
||||||
|
// propagation so its own button/items don't self-close).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toolsOpen) return;
|
||||||
|
const close = () => setToolsOpen(false);
|
||||||
|
window.addEventListener('mousedown', close);
|
||||||
|
return () => window.removeEventListener('mousedown', close);
|
||||||
|
}, [toolsOpen]);
|
||||||
|
|
||||||
// When returning from edit mode, show the edited interface
|
// When returning from edit mode, show the edited interface
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialInterface) setCurrentInterface(initialInterface);
|
if (initialInterface) setCurrentInterface(initialInterface);
|
||||||
@@ -140,14 +152,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<div
|
|
||||||
class={`status-chip ${wsStatus}`}
|
|
||||||
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
|
|
||||||
>
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
|
||||||
{me.user && !writable && ' (read-only)'}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||||
title="Toggle historical time navigation"
|
title="Toggle historical time navigation"
|
||||||
@@ -163,32 +167,29 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
📖
|
📖
|
||||||
</button>
|
</button>
|
||||||
<ZoomControl />
|
<ZoomControl />
|
||||||
{writable && me.canEditLogic && (
|
{hasTools && (
|
||||||
<button
|
<div class="toolbar-dropdown" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
class="toolbar-btn"
|
<button
|
||||||
onClick={() => setShowControlLogic(true)}
|
class={`toolbar-btn${toolsOpen ? ' toolbar-btn-active' : ''}`}
|
||||||
title="Open server-side control logic"
|
title="Advanced tools"
|
||||||
>
|
onClick={() => setToolsOpen(o => !o)}
|
||||||
⚙ Control logic
|
>
|
||||||
</button>
|
⋯ Tools ▾
|
||||||
)}
|
</button>
|
||||||
{me.canViewAudit && (
|
{toolsOpen && (
|
||||||
<button
|
<div class="toolbar-dropdown-menu toolbar-dropdown-menu-right" onClick={() => setToolsOpen(false)}>
|
||||||
class="toolbar-btn"
|
{writable && me.canEditLogic && (
|
||||||
onClick={() => setShowAudit(true)}
|
<button class="ctx-item" onClick={() => setShowControlLogic(true)}>⚙ Control logic</button>
|
||||||
title="View the audit log"
|
)}
|
||||||
>
|
{me.canViewAudit && (
|
||||||
🛡 Audit
|
<button class="ctx-item" onClick={() => setShowAudit(true)}>🛡 Audit log</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
{writable && (
|
||||||
{writable && (
|
<button class="ctx-item" onClick={() => setShowConfig(true)}>🗂 Config manager</button>
|
||||||
<button
|
)}
|
||||||
class="toolbar-btn"
|
</div>
|
||||||
onClick={() => setShowConfig(true)}
|
)}
|
||||||
title="Open configuration manager"
|
</div>
|
||||||
>
|
|
||||||
🗂 Config
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{writable && (
|
{writable && (
|
||||||
<button
|
<button
|
||||||
@@ -269,6 +270,25 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<div class="statusbar-left">
|
||||||
|
{currentInterface
|
||||||
|
? <span class="statusbar-iface" title={currentInterface.name}>{currentInterface.name}</span>
|
||||||
|
: <span class="statusbar-muted">No panel loaded</span>}
|
||||||
|
{!isLive && <span class="statusbar-hist" title="Viewing historical data">⏱ history</span>}
|
||||||
|
</div>
|
||||||
|
<div class="statusbar-right">
|
||||||
|
<div
|
||||||
|
class={`status-chip ${wsStatus}`}
|
||||||
|
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
|
||||||
|
>
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
||||||
|
{me.user && !writable && ' (read-only)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
{showHelp && (
|
{showHelp && (
|
||||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const WIDGET_TYPES = [
|
|||||||
{ type: 'led', label: 'LED', desc: 'State indicator' },
|
{ type: 'led', label: 'LED', desc: 'State indicator' },
|
||||||
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
|
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
|
||||||
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
|
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
|
||||||
|
{ type: 'toggle', label: 'Toggle Switch', desc: 'On/off switch control' },
|
||||||
{ type: 'button', label: 'Button', desc: 'Send command on click' },
|
{ type: 'button', label: 'Button', desc: 'Send command on click' },
|
||||||
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
|
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
|
||||||
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
|
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Cross-plot synchronisation for time-series plot widgets.
|
||||||
|
//
|
||||||
|
// Two things are shared across all *linked* timeseries plots:
|
||||||
|
// 1. The uPlot cursor crosshair, via uPlot's native cursor.sync keyed on
|
||||||
|
// PLOT_SYNC_KEY (hovering one plot draws the matching-time crosshair on the
|
||||||
|
// others). This works in both live and historical mode.
|
||||||
|
// 2. The visible x (time) range, for zoom/pan sync in historical mode — when a
|
||||||
|
// user zooms one plot, the same [min,max] is applied to the others. Live
|
||||||
|
// mode keeps its own rolling window (auto-scroll), so range sync is only
|
||||||
|
// brokered here for historical views.
|
||||||
|
//
|
||||||
|
// A plot can opt out of both via its toolbar "link" toggle.
|
||||||
|
|
||||||
|
export const PLOT_SYNC_KEY = 'uopi-plots';
|
||||||
|
|
||||||
|
export interface XRange {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RangeListener = (r: XRange) => void;
|
||||||
|
|
||||||
|
const rangeListeners = new Map<string, RangeListener>();
|
||||||
|
|
||||||
|
export function subscribeXRange(id: string, fn: RangeListener): () => void {
|
||||||
|
rangeListeners.set(id, fn);
|
||||||
|
return () => {
|
||||||
|
rangeListeners.delete(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast a new visible x-range to every linked plot except the originator.
|
||||||
|
export function broadcastXRange(origin: string, r: XRange) {
|
||||||
|
rangeListeners.forEach((fn, id) => {
|
||||||
|
if (id !== origin) fn(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -39,6 +39,25 @@ export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType
|
|||||||
return { type: 'scalar' };
|
return { type: 'scalar' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// portAccept reports the data type an op's input ports accept:
|
||||||
|
// 'array' — reductions & array producers require an array input
|
||||||
|
// 'scalar' — stateful filters & lua require a scalar input
|
||||||
|
// 'any' — elementwise stateless ops accept either type
|
||||||
|
// Used to colour input ports and to reject definitely-incompatible wirings.
|
||||||
|
export function portAccept(op: string): SynthType | 'any' {
|
||||||
|
if (REDUCTION_OPS.has(op) || ARRAY_PRODUCER_OPS.has(op)) return 'array';
|
||||||
|
if (SCALAR_ONLY_OPS.has(op)) return 'scalar';
|
||||||
|
return 'any';
|
||||||
|
}
|
||||||
|
|
||||||
|
// typesCompatible reports whether a wire carrying `from` may terminate on a port
|
||||||
|
// accepting `accept`. 'unknown' is always allowed (runtime typing is final), as
|
||||||
|
// is an 'any' port — only a definite scalar↔array mismatch is rejected.
|
||||||
|
export function typesCompatible(from: SynthType, accept: SynthType | 'any'): boolean {
|
||||||
|
if (accept === 'any' || from === 'unknown') return true;
|
||||||
|
return from === accept;
|
||||||
|
}
|
||||||
|
|
||||||
// inferNodeTypes walks the DAG in dependency order and assigns each node an
|
// inferNodeTypes walks the DAG in dependency order and assigns each node an
|
||||||
// output type. Source node types come from `sourceType` (resolved from upstream
|
// output type. Source node types come from `sourceType` (resolved from upstream
|
||||||
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
|
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
|
||||||
|
|||||||
@@ -197,6 +197,27 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bottom status bar (connection chip + panel context) ─────────────────── */
|
||||||
|
.statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 1.6rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
background: #141925;
|
||||||
|
border-top: 1px solid #2d3748;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.statusbar-left,
|
||||||
|
.statusbar-right { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
|
||||||
|
.statusbar-iface { color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.statusbar-muted { color: #64748b; }
|
||||||
|
.statusbar-hist { color: #fbbf24; }
|
||||||
|
.statusbar .status-chip { padding: 0.05rem 0.5rem; font-size: 0.7rem; }
|
||||||
|
|
||||||
/* ── Panel resize handle ─────────────────────────────────────────────────── */
|
/* ── Panel resize handle ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.panel-resize-handle {
|
.panel-resize-handle {
|
||||||
@@ -386,6 +407,7 @@ body {
|
|||||||
.canvas-view-bare .led-widget,
|
.canvas-view-bare .led-widget,
|
||||||
.canvas-view-bare .multiled-widget,
|
.canvas-view-bare .multiled-widget,
|
||||||
.canvas-view-bare .setvalue,
|
.canvas-view-bare .setvalue,
|
||||||
|
.canvas-view-bare .toggle-widget,
|
||||||
.canvas-view-bare .plot-widget {
|
.canvas-view-bare .plot-widget {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
@@ -945,6 +967,74 @@ body {
|
|||||||
}
|
}
|
||||||
.sv-btn:disabled:hover { background: #2563eb; }
|
.sv-btn:disabled:hover { background: #2563eb; }
|
||||||
|
|
||||||
|
/* ── Toggle switch widget ──────────────────────────────────────────────── */
|
||||||
|
.toggle-widget {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #1a1f2e;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.toggle-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #3d4f6e;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2e8f0;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-track { background: #22c55e; }
|
||||||
|
.toggle-on .toggle-knob { transform: translateX(16px); }
|
||||||
|
.toggle-unknown .toggle-track { background: #4b5563; }
|
||||||
|
.toggle-state {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
min-width: 1.6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-state { color: #86efac; }
|
||||||
|
.toggle-switch:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Button widget ─────────────────────────────────────────────────────── */
|
/* ── Button widget ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.button-widget {
|
.button-widget {
|
||||||
@@ -1066,6 +1156,85 @@ body {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Plot hover toolbar (pause / window / clear / screenshot) ─────────────── */
|
||||||
|
.plot-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 3px;
|
||||||
|
background: rgba(15, 17, 23, 0.85);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 5px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.plot-widget:hover .plot-toolbar { opacity: 1; pointer-events: auto; }
|
||||||
|
.plot-tb-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plot-tb-btn:hover { background: #2d3748; }
|
||||||
|
.plot-tb-active { background: #2563eb; color: #fff; }
|
||||||
|
.plot-tb-active:hover { background: #1d4ed8; }
|
||||||
|
.plot-tb-select {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #3d4f6e;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 1px 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plot-tb-select:focus { outline: none; border-color: #4a9eff; }
|
||||||
|
|
||||||
|
/* ── Plot measurement readout (cursor A/B + Δt + per-series Δ) ─────────────── */
|
||||||
|
.measure-readout {
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
left: 4px;
|
||||||
|
z-index: 12;
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: rgba(15, 17, 23, 0.9);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #cbd5e1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.measure-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.measure-head {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.measure-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 9rem;
|
||||||
|
}
|
||||||
|
.measure-vals { color: #e2e8f0; }
|
||||||
|
.measure-delta { color: #94a3b8; }
|
||||||
|
|
||||||
/* uPlot dark overrides */
|
/* uPlot dark overrides */
|
||||||
.chart-area .u-wrap { background: transparent; }
|
.chart-area .u-wrap { background: transparent; }
|
||||||
.chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; }
|
.chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; }
|
||||||
@@ -1370,6 +1539,14 @@ body {
|
|||||||
.flow-port-body { border-color: #10b981; }
|
.flow-port-body { border-color: #10b981; }
|
||||||
.flow-port-else { border-color: #ef4444; }
|
.flow-port-else { border-color: #ef4444; }
|
||||||
|
|
||||||
|
/* Synthetic-editor port data-type colouring (scoped to .synth-graph so the
|
||||||
|
Logic / ControlLogic editors that share .flow-port are unaffected). An 'any'
|
||||||
|
port (accepts either type) keeps the default slate; scalar is blue, array
|
||||||
|
purple — mirroring the wire palette so a port previews what it carries/accepts. */
|
||||||
|
.synth-graph .flow-port-type-scalar { border-color: #3b82f6; }
|
||||||
|
.synth-graph .flow-port-type-array { border-color: #a855f7; }
|
||||||
|
.synth-graph .flow-port-type-any { border-color: #64748b; }
|
||||||
|
|
||||||
.flow-port-label {
|
.flow-port-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
@@ -2713,6 +2890,10 @@ body {
|
|||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right-aligned variant (toolbar-right dropdowns expand leftward to stay
|
||||||
|
on-screen instead of overflowing the viewport edge). */
|
||||||
|
.toolbar-dropdown-menu-right { left: auto; right: 0; }
|
||||||
|
|
||||||
/* Align toolbar */
|
/* Align toolbar */
|
||||||
.align-toolbar {
|
.align-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getWidgetCmdStore } from '../lib/widgetCommands';
|
|||||||
import { wsClient } from '../lib/ws';
|
import { wsClient } from '../lib/ws';
|
||||||
import { formatValue } from '../lib/format';
|
import { formatValue } from '../lib/format';
|
||||||
import { fftMag, resampleUniform } from '../lib/fft';
|
import { fftMag, resampleUniform } from '../lib/fft';
|
||||||
|
import { PLOT_SYNC_KEY, subscribeXRange, broadcastXRange } from '../lib/plotSync';
|
||||||
import type { Widget, SignalRef } from '../lib/types';
|
import type { Widget, SignalRef } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,6 +21,19 @@ interface SeriesBuffer {
|
|||||||
values: number[];
|
values: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeasureRow {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
va: number | null;
|
||||||
|
vb: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasureReadout {
|
||||||
|
a: number; // cursor A time (seconds)
|
||||||
|
b: number | null; // cursor B time (seconds), once placed
|
||||||
|
rows: MeasureRow[];
|
||||||
|
}
|
||||||
|
|
||||||
const RING_MAX = 200_000;
|
const RING_MAX = 200_000;
|
||||||
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||||
|
|
||||||
@@ -47,6 +61,39 @@ function windowedSlice(buf: SeriesBuffer, windowSec: number) {
|
|||||||
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact display of a measured value (null → em-dash).
|
||||||
|
function fmtNum(v: number | null): string {
|
||||||
|
if (v == null || !isFinite(v)) return '—';
|
||||||
|
const a = Math.abs(v);
|
||||||
|
if (a !== 0 && (a < 1e-3 || a >= 1e6)) return v.toExponential(3);
|
||||||
|
return v.toFixed(Math.abs(v) >= 100 ? 1 : 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable Δt between two cursors (seconds), with rate where useful.
|
||||||
|
function formatDt(dt: number): string {
|
||||||
|
const a = Math.abs(dt);
|
||||||
|
if (a < 1) return `${(dt * 1000).toFixed(1)} ms`;
|
||||||
|
if (a < 60) return `${dt.toFixed(3)} s (${(1 / dt).toFixed(2)} Hz)`;
|
||||||
|
if (a < 3600) return `${(dt / 60).toFixed(2)} min`;
|
||||||
|
return `${(dt / 3600).toFixed(2)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step-hold value of a series at time t (seconds): the most recent sample whose
|
||||||
|
// timestamp is <= t. Used by the measurement cursors. Binary search over the
|
||||||
|
// (sorted) timestamp buffer.
|
||||||
|
function valueAt(buf: SeriesBuffer, t: number): number | null {
|
||||||
|
const ts = buf.timestamps;
|
||||||
|
if (ts.length === 0) return null;
|
||||||
|
let lo = 0, hi = ts.length - 1, res = -1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (ts[mid] <= t) { res = mid; lo = mid + 1; }
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
if (res === -1) return null; // t is before the first sample
|
||||||
|
return buf.values[res];
|
||||||
|
}
|
||||||
|
|
||||||
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
|
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
|
||||||
const BUCKETS = 20;
|
const BUCKETS = 20;
|
||||||
let lo = Infinity, hi = -Infinity;
|
let lo = Infinity, hi = -Infinity;
|
||||||
@@ -67,6 +114,108 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
const chartRef = useRef<HTMLDivElement>(null);
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
|
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
|
||||||
|
|
||||||
|
// Live chart instances + sample buffers held in refs so the hover toolbar can
|
||||||
|
// pause/clear/screenshot without tearing down and recreating the chart. The
|
||||||
|
// buffers persist across effect re-runs (plot-type / window / legend changes),
|
||||||
|
// keyed by widget+signals+time-range so only a genuine data-source change
|
||||||
|
// resets them.
|
||||||
|
const uplotRef = useRef<uPlot | null>(null);
|
||||||
|
const echartRef = useRef<echarts.EChartsType | null>(null);
|
||||||
|
const buffersRef = useRef<{ key: string; bufs: SeriesBuffer[]; waves: number[][] } | null>(null);
|
||||||
|
// Toolbar state: local pause (OR'd with panel-logic pause) and a view-time
|
||||||
|
// rolling-window override (null = use the configured timeWindow option).
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const pausedRef = useRef(false);
|
||||||
|
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||||
|
const [winOverride, setWinOverride] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Cross-plot link: when on, this plot joins the shared cursor + x-range sync
|
||||||
|
// group (see lib/plotSync). Toggling re-inits the chart (cursor.sync is an
|
||||||
|
// init-time uPlot option); buffers persist via buffersRef.
|
||||||
|
const [linked, setLinked] = useState(true);
|
||||||
|
// Measurement cursors: marksRef holds two x-times (A/B) on the time axis; the
|
||||||
|
// readout panel shows Δt plus each series' value at A, B and ΔV. Toggling
|
||||||
|
// measure mode does NOT re-init the chart — the draw hook and click handler
|
||||||
|
// read live refs and a redraw() is enough.
|
||||||
|
const [measureOn, setMeasureOn] = useState(false);
|
||||||
|
const measureOnRef = useRef(false);
|
||||||
|
const marksRef = useRef<{ a: number | null; b: number | null }>({ a: null, b: null });
|
||||||
|
const [readout, setReadout] = useState<MeasureReadout | null>(null);
|
||||||
|
// Guards a feedback loop when applying a synced x-range back into uPlot.
|
||||||
|
const applyingSyncRef = useRef(false);
|
||||||
|
|
||||||
|
// Recompute the measurement readout from the current marks + buffers.
|
||||||
|
function refreshReadout() {
|
||||||
|
const { a, b } = marksRef.current;
|
||||||
|
const b0 = buffersRef.current?.bufs;
|
||||||
|
if (a == null || !b0) { setReadout(null); return; }
|
||||||
|
const rows: MeasureRow[] = widget.signals.map((s, i) => ({
|
||||||
|
name: s.name,
|
||||||
|
color: s.color ?? COLORS[i % COLORS.length],
|
||||||
|
va: b0[i] ? valueAt(b0[i], a) : null,
|
||||||
|
vb: b != null && b0[i] ? valueAt(b0[i], b) : null,
|
||||||
|
}));
|
||||||
|
setReadout({ a, b, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place / move measurement cursors on click while in measure mode.
|
||||||
|
function onMeasureClick() {
|
||||||
|
const u = uplotRef.current;
|
||||||
|
const left = u ? (u as any).cursor?.left : null;
|
||||||
|
if (!measureOnRef.current || !u || left == null || left < 0) return;
|
||||||
|
const val = (u as any).posToVal(left, 'x');
|
||||||
|
const m = marksRef.current;
|
||||||
|
// 1st click (or restart after both set) → A; 2nd click → B.
|
||||||
|
if (m.a == null || m.b != null) { m.a = val; m.b = null; }
|
||||||
|
else m.b = val;
|
||||||
|
refreshReadout();
|
||||||
|
(u as any).redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMeasure() {
|
||||||
|
const on = !measureOnRef.current;
|
||||||
|
measureOnRef.current = on;
|
||||||
|
setMeasureOn(on);
|
||||||
|
if (!on) { marksRef.current = { a: null, b: null }; setReadout(null); }
|
||||||
|
(uplotRef.current as any)?.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolbar handlers (operate on the live ref instances) ──────────────────
|
||||||
|
function clearPlot() {
|
||||||
|
const b = buffersRef.current;
|
||||||
|
if (!b) return;
|
||||||
|
b.bufs.forEach(buf => { buf.timestamps.length = 0; buf.values.length = 0; });
|
||||||
|
b.waves.forEach(w => { w.length = 0; });
|
||||||
|
uplotRef.current?.setData([[], ...b.bufs.map(() => [])] as uPlot.AlignedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenshot() {
|
||||||
|
let dataUrl: string | undefined;
|
||||||
|
const canvas = chartRef.current?.querySelector('canvas');
|
||||||
|
if (uplotRef.current && canvas) {
|
||||||
|
dataUrl = canvas.toDataURL('image/png');
|
||||||
|
} else if (echartRef.current) {
|
||||||
|
dataUrl = (echartRef.current as any).getDataURL({ pixelRatio: 2, backgroundColor: '#1a1f2e' });
|
||||||
|
}
|
||||||
|
if (!dataUrl) return;
|
||||||
|
const name = (widget.signals[0]?.name ?? 'plot').replace(/[^\w.-]+/g, '_');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = `${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rolling-window presets (seconds); null = use the widget's configured window.
|
||||||
|
const WINDOW_PRESETS: { label: string; val: number | null }[] = [
|
||||||
|
{ label: 'Auto', val: null },
|
||||||
|
{ label: '15s', val: 15 },
|
||||||
|
{ label: '30s', val: 30 },
|
||||||
|
{ label: '1m', val: 60 },
|
||||||
|
{ label: '5m', val: 300 },
|
||||||
|
{ label: '15m', val: 900 },
|
||||||
|
{ label: '1h', val: 3600 },
|
||||||
|
];
|
||||||
|
|
||||||
// Stable primitive deps so the effect re-runs when plotType or signals change.
|
// Stable primitive deps so the effect re-runs when plotType or signals change.
|
||||||
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
|
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
|
||||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||||
@@ -77,15 +226,26 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
if (!chartRef.current) return;
|
if (!chartRef.current) return;
|
||||||
|
|
||||||
const signals = widget.signals;
|
const signals = widget.signals;
|
||||||
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
|
const timeWindow = winOverride ?? parseFloat(widget.options['timeWindow'] ?? '60');
|
||||||
const yMin = widget.options['yMin'];
|
const yMin = widget.options['yMin'];
|
||||||
const yMax = widget.options['yMax'];
|
const yMax = widget.options['yMax'];
|
||||||
const legendPos = widget.options['legend'] ?? 'bottom';
|
const legendPos = widget.options['legend'] ?? 'bottom';
|
||||||
const showLegend = legendPos !== 'none';
|
const showLegend = legendPos !== 'none';
|
||||||
|
|
||||||
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
// Reuse persisted buffers across effect re-runs (plot-type / window / legend
|
||||||
|
// changes) so the toolbar and a window switch don't drop live history; reset
|
||||||
|
// only when the widget, its signals or the time-range change.
|
||||||
|
const bufKey = `${widget.id}|${signalsKey}|${timeRangeKey}`;
|
||||||
|
if (!buffersRef.current || buffersRef.current.key !== bufKey) {
|
||||||
|
buffersRef.current = {
|
||||||
|
key: bufKey,
|
||||||
|
bufs: signals.map(() => ({ timestamps: [], values: [] })),
|
||||||
|
waves: signals.map(() => []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const buffers: SeriesBuffer[] = buffersRef.current.bufs;
|
||||||
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
|
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
|
||||||
const waveforms: number[][] = signals.map(() => []);
|
const waveforms: number[][] = buffersRef.current.waves;
|
||||||
const unsubs: (() => void)[] = [];
|
const unsubs: (() => void)[] = [];
|
||||||
const fmt = widget.options['format'] ?? '';
|
const fmt = widget.options['format'] ?? '';
|
||||||
|
|
||||||
@@ -139,6 +299,29 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
] as uPlot.AlignedData;
|
] as uPlot.AlignedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw the measurement cursor lines (A=amber, B=green) onto the uPlot canvas
|
||||||
|
// so they track the data on zoom / pan / resize. Runs in the 'draw' hook.
|
||||||
|
function drawMeasureMarks(u: uPlot) {
|
||||||
|
const { a, b } = marksRef.current;
|
||||||
|
if (a == null && b == null) return;
|
||||||
|
const ctx = (u as any).ctx as CanvasRenderingContext2D;
|
||||||
|
const bb = (u as any).bbox as { top: number; height: number };
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 3]);
|
||||||
|
const line = (val: number, color: string) => {
|
||||||
|
const x = (u as any).valToPos(val, 'x', true);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, bb.top);
|
||||||
|
ctx.lineTo(x, bb.top + bb.height);
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
if (a != null) line(a, '#f59e0b');
|
||||||
|
if (b != null) line(b, '#22c55e');
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function makeUplotOpts(): uPlot.Options {
|
function makeUplotOpts(): uPlot.Options {
|
||||||
const yAxis: uPlot.Axis = {
|
const yAxis: uPlot.Axis = {
|
||||||
stroke: '#94a3b8',
|
stroke: '#94a3b8',
|
||||||
@@ -194,6 +377,21 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
},
|
},
|
||||||
y: { ...scaleY, range: scaleYRange },
|
y: { ...scaleY, range: scaleYRange },
|
||||||
},
|
},
|
||||||
|
// Cross-plot crosshair sync (x only) for all linked plots.
|
||||||
|
cursor: linked ? { sync: { key: PLOT_SYNC_KEY, scales: ['x', null] } } : {},
|
||||||
|
hooks: {
|
||||||
|
draw: [drawMeasureMarks],
|
||||||
|
// Broadcast zoom/pan to other linked plots (historical mode only — live
|
||||||
|
// mode auto-scrolls its own rolling window). Guard against the echo of
|
||||||
|
// an applied sync to avoid a feedback loop.
|
||||||
|
setScale: [(u: uPlot, key: string) => {
|
||||||
|
if (key !== 'x' || !linked || !timeRange || applyingSyncRef.current) return;
|
||||||
|
const sc = (u as any).scales.x as { min: number | null; max: number | null };
|
||||||
|
if (sc.min != null && sc.max != null) {
|
||||||
|
broadcastXRange(widget.id, { min: sc.min, max: sc.max });
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +571,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
if (plotType !== 'timeseries') {
|
if (plotType !== 'timeseries') {
|
||||||
echart = echarts.init(el);
|
echart = echarts.init(el);
|
||||||
echart.setOption(echartsOption());
|
echart.setOption(echartsOption());
|
||||||
|
echartRef.current = echart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire a freshly-created uPlot into the refs + measurement click handler.
|
||||||
|
function mountUplot(u: uPlot) {
|
||||||
|
uplotRef.current = u;
|
||||||
|
(u as any).over.addEventListener('click', onMeasureClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a synced x-range pushed from a peer plot (historical zoom/pan sync).
|
||||||
|
if (linked && plotType === 'timeseries') {
|
||||||
|
unsubs.push(subscribeXRange(widget.id, r => {
|
||||||
|
const u = uplotRef.current;
|
||||||
|
if (!u || !timeRange) return;
|
||||||
|
applyingSyncRef.current = true;
|
||||||
|
(u as any).setScale('x', { min: r.min, max: r.max });
|
||||||
|
applyingSyncRef.current = false;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Historical mode (timeseries only) ──────────────────────────────────
|
// ── Historical mode (timeseries only) ──────────────────────────────────
|
||||||
@@ -403,20 +619,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
|
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
|
||||||
// Create uPlot now — with real data — so the x-axis is correct from the start.
|
// Create uPlot now — with real data — so the x-axis is correct from the start.
|
||||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||||
|
mountUplot(uplot);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!cancelled) setHistStatus('error');
|
if (!cancelled) setHistStatus('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
unsubs.forEach(u => u());
|
||||||
uplot?.destroy();
|
uplot?.destroy();
|
||||||
echart?.dispose();
|
echart?.dispose();
|
||||||
|
uplotRef.current = null;
|
||||||
|
echartRef.current = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live mode: init uPlot immediately ──────────────────────────────────
|
// ── Live mode: init uPlot immediately ──────────────────────────────────
|
||||||
if (plotType === 'timeseries') {
|
if (plotType === 'timeseries') {
|
||||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||||
|
mountUplot(uplot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live streaming mode ────────────────────────────────────────────────
|
// ── Live streaming mode ────────────────────────────────────────────────
|
||||||
@@ -425,10 +646,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
// Honour action.widget logic commands: `paused` suspends live ingestion and
|
// Honour action.widget logic commands: `paused` suspends live ingestion and
|
||||||
// a bumped `clearNonce` empties the buffers + redraws once.
|
// a bumped `clearNonce` empties the buffers + redraws once.
|
||||||
const cmdStore = getWidgetCmdStore(widget.id);
|
const cmdStore = getWidgetCmdStore(widget.id);
|
||||||
let paused = cmdStore.get().paused;
|
let cmdPaused = cmdStore.get().paused;
|
||||||
let lastClear = cmdStore.get().clearNonce;
|
let lastClear = cmdStore.get().clearNonce;
|
||||||
unsubs.push(cmdStore.subscribe(cmd => {
|
unsubs.push(cmdStore.subscribe(cmd => {
|
||||||
paused = cmd.paused;
|
cmdPaused = cmd.paused;
|
||||||
if (cmd.clearNonce !== lastClear) {
|
if (cmd.clearNonce !== lastClear) {
|
||||||
lastClear = cmd.clearNonce;
|
lastClear = cmd.clearNonce;
|
||||||
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
|
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
|
||||||
@@ -439,7 +660,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
|
|
||||||
signals.forEach((sig: SignalRef & { color?: string }, i) => {
|
signals.forEach((sig: SignalRef & { color?: string }, i) => {
|
||||||
const unsub = getSignalStore(sig).subscribe(sv => {
|
const unsub = getSignalStore(sig).subscribe(sv => {
|
||||||
if (paused) return;
|
if (cmdPaused || pausedRef.current) return;
|
||||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||||
const ts = new Date(sv.ts).getTime() / 1000;
|
const ts = new Date(sv.ts).getTime() / 1000;
|
||||||
|
|
||||||
@@ -487,8 +708,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
unsubs.forEach(u => u());
|
unsubs.forEach(u => u());
|
||||||
if (uplot) uplot.destroy();
|
if (uplot) uplot.destroy();
|
||||||
if (echart) echart.dispose();
|
if (echart) echart.dispose();
|
||||||
|
uplotRef.current = null;
|
||||||
|
echartRef.current = null;
|
||||||
};
|
};
|
||||||
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
|
}, [widget.id, timeRangeKey, plotType, signalsKey, winOverride, linked, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -497,6 +720,61 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
>
|
>
|
||||||
|
<div class="plot-toolbar" onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
class={`plot-tb-btn${paused ? ' plot-tb-active' : ''}`}
|
||||||
|
title={paused ? 'Resume live updates' : 'Pause live updates'}
|
||||||
|
onClick={() => setPaused(p => !p)}
|
||||||
|
>{paused ? '▶' : '⏸'}</button>
|
||||||
|
{!timeRange && (plotType === 'timeseries' || plotType === 'fft' || plotType === 'logic') && (
|
||||||
|
<select
|
||||||
|
class="plot-tb-select"
|
||||||
|
title="Rolling time window"
|
||||||
|
value={winOverride === null ? 'auto' : String(winOverride)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = (e.target as HTMLSelectElement).value;
|
||||||
|
setWinOverride(v === 'auto' ? null : parseFloat(v));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WINDOW_PRESETS.map(p => (
|
||||||
|
<option key={p.label} value={p.val === null ? 'auto' : String(p.val)}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{plotType === 'timeseries' && (
|
||||||
|
<button
|
||||||
|
class={`plot-tb-btn${linked ? ' plot-tb-active' : ''}`}
|
||||||
|
title={linked ? 'Linked: cursor / zoom synced with other plots — click to unlink' : 'Unlinked — click to sync cursor / zoom with other plots'}
|
||||||
|
onClick={() => setLinked(v => !v)}
|
||||||
|
>{linked ? '🔗' : '⛓️💥'}</button>
|
||||||
|
)}
|
||||||
|
{plotType === 'timeseries' && (
|
||||||
|
<button
|
||||||
|
class={`plot-tb-btn${measureOn ? ' plot-tb-active' : ''}`}
|
||||||
|
title="Measure: click to place cursor A then B; read Δt and per-signal Δ"
|
||||||
|
onClick={toggleMeasure}
|
||||||
|
>📏</button>
|
||||||
|
)}
|
||||||
|
<button class="plot-tb-btn" title="Clear buffered data" onClick={clearPlot}>🗑</button>
|
||||||
|
<button class="plot-tb-btn" title="Save screenshot (PNG)" onClick={screenshot}>📷</button>
|
||||||
|
</div>
|
||||||
|
{readout && plotType === 'timeseries' && (
|
||||||
|
<div class="measure-readout" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div class="measure-row measure-head">
|
||||||
|
<span>Δt</span>
|
||||||
|
<span>{readout.b != null ? formatDt(readout.b - readout.a) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
{readout.rows.map(r => (
|
||||||
|
<div class="measure-row" key={r.name}>
|
||||||
|
<span class="measure-name" style={`color:${r.color}`}>{r.name}</span>
|
||||||
|
<span class="measure-vals">
|
||||||
|
{fmtNum(r.va)}
|
||||||
|
{readout.b != null && <span class="measure-delta"> → {fmtNum(r.vb)} (Δ {fmtNum(r.va != null && r.vb != null ? r.vb - r.va : null)})</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
||||||
{histStatus === 'loading' && (
|
{histStatus === 'loading' && (
|
||||||
<div class="hist-overlay">Loading history…</div>
|
<div class="hist-overlay">Loading history…</div>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { wsClient } from '../lib/ws';
|
||||||
|
import { useAuth, canWrite } from '../lib/auth';
|
||||||
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
// Compare a live signal value against a configured on/off target. Numeric when
|
||||||
|
// both parse as finite numbers (tolerant compare), otherwise string equality.
|
||||||
|
function matches(value: unknown, target: string): boolean {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
const vn = typeof value === 'number' ? value : parseFloat(String(value));
|
||||||
|
const tn = parseFloat(target);
|
||||||
|
if (Number.isFinite(vn) && Number.isFinite(tn)) return Math.abs(vn - tn) < 1e-9;
|
||||||
|
return String(value) === target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toggle({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const me = useAuth();
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||||
|
return () => { unsubV(); unsubM(); };
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] || sigRef?.name || '';
|
||||||
|
const onValue = widget.options['onValue'] ?? '1';
|
||||||
|
const offValue = widget.options['offValue'] ?? '0';
|
||||||
|
const onLabel = widget.options['onLabel'] || 'ON';
|
||||||
|
const offLabel = widget.options['offLabel'] || 'OFF';
|
||||||
|
const confirm = widget.options['confirm'] === 'true';
|
||||||
|
|
||||||
|
const isLocal = sigRef?.ds === 'local';
|
||||||
|
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
|
||||||
|
|
||||||
|
// "On" when the value matches the configured on-target; unknown until a value
|
||||||
|
// arrives. A value matching neither target is treated as off.
|
||||||
|
const known = sv.value !== null && sv.value !== undefined;
|
||||||
|
const isOn = known && matches(sv.value, onValue);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!sigRef || !writeAllowed) return;
|
||||||
|
const target = isOn ? offValue : onValue;
|
||||||
|
const numeric = Number.isFinite(parseFloat(target)) && meta?.type !== 'string';
|
||||||
|
const write = () => wsClient.write(sigRef, numeric ? parseFloat(target) : target);
|
||||||
|
if (confirm) {
|
||||||
|
if (window.confirm(`Set ${label} to ${isOn ? offLabel : onLabel}?`)) write();
|
||||||
|
} else {
|
||||||
|
write();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="toggle-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{label && <span class="toggle-label">{label}</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`toggle-switch${isOn ? ' toggle-on' : ''}${!known ? ' toggle-unknown' : ''}`}
|
||||||
|
disabled={!writeAllowed}
|
||||||
|
onClick={toggle}
|
||||||
|
title={writeAllowed ? '' : 'You do not have permission to write this signal'}
|
||||||
|
>
|
||||||
|
<span class="toggle-track"><span class="toggle-knob" /></span>
|
||||||
|
<span class="toggle-state">{!known ? '—' : isOn ? onLabel : offLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "new-config-set-snapshot-2026-06-21-23-53-12",
|
||||||
|
"name": "New config set snapshot 2026-06-21 23:53:12",
|
||||||
|
"owner": "martino",
|
||||||
|
"setId": "new-config-set",
|
||||||
|
"values": {
|
||||||
|
"param1": 0,
|
||||||
|
"param2": 42.60427155337898,
|
||||||
|
"param3": "System nominal",
|
||||||
|
"param4": "Idle"
|
||||||
|
},
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user