diff --git a/TODO.md b/TODO.md index eaa4f99..3d6ab4b 100644 --- a/TODO.md +++ b/TODO.md @@ -41,28 +41,31 @@ - [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`) - [ ] Improve UX: - [ ] Synthetic editor: - - [ ] color code the node link by type - - [ ] edges color depends on type (e.g. float) including arrays - - [ ] input / outputs are color coded (if can accept many, gray, otherwise specific color, can be dynamic) - - [ ] can not connect input / output that are not compatible + - [x] color code the node link by type + - [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple) + - [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either) + - [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] add proper array functionality - [x] add shortcut to add Signals (S) and nodes (N) with HUD - [ ] Panels: - [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 + - [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 - [ ] plot pane: - - [ ] add toolbar - - [ ] add time window selector to toolbar - - [ ] plot x-axis synchronised (option to unlink in toolbar) - - [ ] cursors and measuraments - - [ ] pause/resume - - [ ] save screenshot - - [ ] clean ui: - - create small statusbar where connection widget and other status related info will be placed - - group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar - - make the toolbar as clean as possible + - [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`) + - [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types) + - [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`) + - [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 Ξ”) + - [x] pause/resume (local toolbar pause, OR'd with panel-logic pause; buffers persist across plot-type/window changes) + - [x] save screenshot (PNG export β€” uPlot canvas / ECharts getDataURL) + - [x] clean ui: + - [x] create small statusbar where connection widget and other status related info will be placed (bottom `.statusbar`: connection chip + current panel name + history indicator) + - [x] group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar (β‹― Tools β–Ύ dropdown) + - [x] make the toolbar as clean as possible (toolbar-right now: History Β· πŸ“– Β· zoom Β· Tools Β· Edit) - [ ] Logic editor: - add full support to local array values: dynamic, dynamic but capped max, fixed size etc: - array functions should work with new local array diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index 4c15812..b409ee3 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -12,6 +12,7 @@ import BarV from './widgets/BarV'; import Led from './widgets/Led'; import MultiLed from './widgets/MultiLed'; import SetValue from './widgets/SetValue'; +import Toggle from './widgets/Toggle'; import Button from './widgets/Button'; import PlotWidget from './widgets/PlotWidget'; import ImageWidget from './widgets/ImageWidget'; @@ -31,6 +32,7 @@ const COMPONENTS: Record = { led: Led, multiled: MultiLed, setvalue: SetValue, + toggle: Toggle, button: Button, plot: PlotWidget, image: ImageWidget, diff --git a/web/src/EditCanvas.tsx b/web/src/EditCanvas.tsx index 0f6f3a7..bb9b43c 100644 --- a/web/src/EditCanvas.tsx +++ b/web/src/EditCanvas.tsx @@ -9,6 +9,7 @@ import BarV from './widgets/BarV'; import Led from './widgets/Led'; import MultiLed from './widgets/MultiLed'; import SetValue from './widgets/SetValue'; +import Toggle from './widgets/Toggle'; import Button from './widgets/Button'; import PlotWidget from './widgets/PlotWidget'; import ImageWidget from './widgets/ImageWidget'; @@ -18,7 +19,7 @@ import WidgetTypePicker from './WidgetTypePicker'; const COMPONENTS: Record = { textview: TextView, textlabel: TextLabel, gauge: Gauge, 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, }; @@ -31,6 +32,7 @@ export const DEFAULT_SIZES: Record = { led: [60, 60], multiled: [200, 80], setvalue: [200, 60], + toggle: [140, 50], button: [120, 50], plot: [400, 200], image: [200, 150], @@ -41,7 +43,7 @@ export const DEFAULT_SIZES: Record = { // Widget types that support multiple signals (signal can be appended) const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']); // Widget types that have exactly one signal (signal can be replaced) -const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']); +const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'toggle', 'button']); interface PickerState { visible: boolean; diff --git a/web/src/PropertiesPane.tsx b/web/src/PropertiesPane.tsx index e11c18d..3b95687 100644 --- a/web/src/PropertiesPane.tsx +++ b/web/src/PropertiesPane.tsx @@ -49,7 +49,7 @@ const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']); // Widgets where the user can set a numeric format string const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']); // 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 const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']); @@ -247,6 +247,30 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange, )} + {w.type === 'toggle' && ( +
+ + setOpt('onValue', v)} /> + + + setOpt('offValue', v)} /> + + + setOpt('onLabel', v)} /> + + + setOpt('offLabel', v)} /> + + + + +
+ )} + {/* Button options (issue #2) */} {w.type === 'button' && (
diff --git a/web/src/SyntheticGraphEditor.tsx b/web/src/SyntheticGraphEditor.tsx index 5b5b2f4..9a6a26e 100644 --- a/web/src/SyntheticGraphEditor.tsx +++ b/web/src/SyntheticGraphEditor.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; import LuaEditor from './LuaEditor'; 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'; 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 tn = graph.nodes.find(n => n.id === to); 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. 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; @@ -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 { const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow'; 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); return ( -
e.stopPropagation()} onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} /> @@ -858,7 +880,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o ); })} {hasOutput(node.kind) && ( -
startWire(e, node)} /> )} diff --git a/web/src/ViewMode.tsx b/web/src/ViewMode.tsx index acde01a..5e394f3 100644 --- a/web/src/ViewMode.tsx +++ b/web/src/ViewMode.tsx @@ -36,10 +36,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { const [showControlLogic, setShowControlLogic] = useState(false); const [showAudit, setShowAudit] = useState(false); const [showConfig, setShowConfig] = useState(false); + const [toolsOpen, setToolsOpen] = useState(false); const [leftW, setLeftW] = useState(220); const [listCollapsed, setListCollapsed] = useState(false); const me = useAuth(); 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) { e.preventDefault(); @@ -69,6 +72,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { 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 useEffect(() => { if (initialInterface) setCurrentInterface(initialInterface); @@ -140,14 +152,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { )}
-
- - {wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus} - {me.user && !writable && ' (read-only)'} -
- )} - {me.canViewAudit && ( - - )} - {writable && ( - + {hasTools && ( +
e.stopPropagation()}> + + {toolsOpen && ( +
setToolsOpen(false)}> + {writable && me.canEditLogic && ( + + )} + {me.canViewAudit && ( + + )} + {writable && ( + + )} +
+ )} +
)} {writable && (
+
+
+ {currentInterface + ? {currentInterface.name} + : No panel loaded} + {!isLive && ⏱ history} +
+
+
+ + {wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus} + {me.user && !writable && ' (read-only)'} +
+
+
+ {showHelp && ( setShowHelp(false)} /> )} diff --git a/web/src/WidgetTypePicker.tsx b/web/src/WidgetTypePicker.tsx index 0c9398f..86de0f5 100644 --- a/web/src/WidgetTypePicker.tsx +++ b/web/src/WidgetTypePicker.tsx @@ -15,6 +15,7 @@ const WIDGET_TYPES = [ { type: 'led', label: 'LED', desc: 'State indicator' }, { type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' }, { 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: 'plot', label: 'Plot', desc: 'Time-series / charts' }, { type: 'textlabel', label: 'Label', desc: 'Static text label' }, diff --git a/web/src/lib/plotSync.ts b/web/src/lib/plotSync.ts new file mode 100644 index 0000000..1cfbc8b --- /dev/null +++ b/web/src/lib/plotSync.ts @@ -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(); + +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); + }); +} diff --git a/web/src/lib/synthTypes.ts b/web/src/lib/synthTypes.ts index 3c815e6..e0ce6bc 100644 --- a/web/src/lib/synthTypes.ts +++ b/web/src/lib/synthTypes.ts @@ -39,6 +39,25 @@ export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType 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 // output type. Source node types come from `sourceType` (resolved from upstream // signal metadata); unresolved sources default to 'unknown'. `errors` collects diff --git a/web/src/styles.css b/web/src/styles.css index 6096640..7a01ee6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -197,6 +197,27 @@ body { 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 { @@ -386,6 +407,7 @@ body { .canvas-view-bare .led-widget, .canvas-view-bare .multiled-widget, .canvas-view-bare .setvalue, +.canvas-view-bare .toggle-widget, .canvas-view-bare .plot-widget { background: transparent; border-color: transparent; @@ -945,6 +967,74 @@ body { } .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 { @@ -1066,6 +1156,85 @@ body { 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 */ .chart-area .u-wrap { background: transparent; } .chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; } @@ -1370,6 +1539,14 @@ body { .flow-port-body { border-color: #10b981; } .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 { position: absolute; right: 10px; @@ -2713,6 +2890,10 @@ body { 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 { display: flex; diff --git a/web/src/widgets/PlotWidget.tsx b/web/src/widgets/PlotWidget.tsx index 5c908c1..778ac04 100644 --- a/web/src/widgets/PlotWidget.tsx +++ b/web/src/widgets/PlotWidget.tsx @@ -7,6 +7,7 @@ import { getWidgetCmdStore } from '../lib/widgetCommands'; import { wsClient } from '../lib/ws'; import { formatValue } from '../lib/format'; import { fftMag, resampleUniform } from '../lib/fft'; +import { PLOT_SYNC_KEY, subscribeXRange, broadcastXRange } from '../lib/plotSync'; import type { Widget, SignalRef } from '../lib/types'; interface Props { @@ -20,6 +21,19 @@ interface SeriesBuffer { 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 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) }; } +// 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[][] } { const BUCKETS = 20; let lo = Infinity, hi = -Infinity; @@ -67,6 +114,108 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) const chartRef = useRef(null); 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(null); + const echartRef = useRef(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(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(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. const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live'; const plotType = widget.options['plotType'] ?? 'timeseries'; @@ -77,15 +226,26 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) if (!chartRef.current) return; 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 yMax = widget.options['yMax']; const legendPos = widget.options['legend'] ?? 'bottom'; 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'. - const waveforms: number[][] = signals.map(() => []); + const waveforms: number[][] = buffersRef.current.waves; const unsubs: (() => void)[] = []; const fmt = widget.options['format'] ?? ''; @@ -139,6 +299,29 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) ] 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 { const yAxis: uPlot.Axis = { stroke: '#94a3b8', @@ -194,6 +377,21 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) }, 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') { echart = echarts.init(el); 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) ────────────────────────────────── @@ -403,20 +619,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) setHistStatus(totalPoints === 0 ? 'empty' : 'idle'); // Create uPlot now β€” with real data β€” so the x-axis is correct from the start. uplot = new uPlot(makeUplotOpts(), uplotData(), el); + mountUplot(uplot); }).catch(() => { if (!cancelled) setHistStatus('error'); }); return () => { cancelled = true; + unsubs.forEach(u => u()); uplot?.destroy(); echart?.dispose(); + uplotRef.current = null; + echartRef.current = null; }; } // ── Live mode: init uPlot immediately ────────────────────────────────── if (plotType === 'timeseries') { uplot = new uPlot(makeUplotOpts(), uplotData(), el); + mountUplot(uplot); } // ── 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 // a bumped `clearNonce` empties the buffers + redraws once. const cmdStore = getWidgetCmdStore(widget.id); - let paused = cmdStore.get().paused; + let cmdPaused = cmdStore.get().paused; let lastClear = cmdStore.get().clearNonce; unsubs.push(cmdStore.subscribe(cmd => { - paused = cmd.paused; + cmdPaused = cmd.paused; if (cmd.clearNonce !== lastClear) { lastClear = cmd.clearNonce; 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) => { 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; const ts = new Date(sv.ts).getTime() / 1000; @@ -487,8 +708,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) unsubs.forEach(u => u()); if (uplot) uplot.destroy(); 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 (
+
e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}> + + {!timeRange && (plotType === 'timeseries' || plotType === 'fft' || plotType === 'logic') && ( + + )} + {plotType === 'timeseries' && ( + + )} + {plotType === 'timeseries' && ( + + )} + + +
+ {readout && plotType === 'timeseries' && ( +
e.stopPropagation()}> +
+ Ξ”t + {readout.b != null ? formatDt(readout.b - readout.a) : 'β€”'} +
+ {readout.rows.map(r => ( +
+ {r.name} + + {fmtNum(r.va)} + {readout.b != null && β†’ {fmtNum(r.vb)} (Ξ” {fmtNum(r.va != null && r.vb != null ? r.vb - r.va : null)})} + +
+ ))} +
+ )}
{histStatus === 'loading' && (
Loading history…
diff --git a/web/src/widgets/Toggle.tsx b/web/src/widgets/Toggle.tsx new file mode 100644 index 0000000..18e4f99 --- /dev/null +++ b/web/src/widgets/Toggle.tsx @@ -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(DEFAULT_VALUE); + const [meta, setMeta] = useState(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 ( +
+ {label && {label}} + +
+ ); +} diff --git a/workspace/data/audit.db b/workspace/data/audit.db index aebe5de..5df6f5f 100644 Binary files a/workspace/data/audit.db and b/workspace/data/audit.db differ diff --git a/workspace/data/configs/instances/new-config-set-snapshot-2026-06-21-23-53-12.json b/workspace/data/configs/instances/new-config-set-snapshot-2026-06-21-23-53-12.json new file mode 100644 index 0000000..79bb3e8 --- /dev/null +++ b/workspace/data/configs/instances/new-config-set-snapshot-2026-06-21-23-53-12.json @@ -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 +} \ No newline at end of file