Improved plot view

This commit is contained in:
Martino Ferrari
2026-06-22 00:30:15 +02:00
parent 113e5a0fe8
commit 73fcbe7b28
14 changed files with 744 additions and 61 deletions
+17 -14
View File
@@ -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
+2
View File
@@ -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,
+4 -2
View File
@@ -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;
+25 -1
View File
@@ -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>
+25 -3
View File
@@ -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)} />
)} )}
+47 -27
View File
@@ -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 && (
<div class="toolbar-dropdown" onMouseDown={(e) => e.stopPropagation()}>
<button <button
class="toolbar-btn" class={`toolbar-btn${toolsOpen ? ' toolbar-btn-active' : ''}`}
onClick={() => setShowControlLogic(true)} title="Advanced tools"
title="Open server-side control logic" onClick={() => setToolsOpen(o => !o)}
> >
Control logic Tools
</button> </button>
{toolsOpen && (
<div class="toolbar-dropdown-menu toolbar-dropdown-menu-right" onClick={() => setToolsOpen(false)}>
{writable && me.canEditLogic && (
<button class="ctx-item" onClick={() => setShowControlLogic(true)}> Control logic</button>
)} )}
{me.canViewAudit && ( {me.canViewAudit && (
<button <button class="ctx-item" onClick={() => setShowAudit(true)}>🛡 Audit log</button>
class="toolbar-btn"
onClick={() => setShowAudit(true)}
title="View the audit log"
>
🛡 Audit
</button>
)} )}
{writable && ( {writable && (
<button <button class="ctx-item" onClick={() => setShowConfig(true)}>🗂 Config manager</button>
class="toolbar-btn" )}
onClick={() => setShowConfig(true)} </div>
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)} />
)} )}
+1
View File
@@ -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' },
+37
View File
@@ -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);
});
}
+19
View File
@@ -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
+181
View File
@@ -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;
+285 -7
View File
@@ -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>
+81
View File
@@ -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
}