Improved UI

This commit is contained in:
Martino Ferrari
2026-05-06 15:55:45 +02:00
parent 0a5a85e4c4
commit 912ecdd9ed
19 changed files with 1141 additions and 279 deletions
+112 -54
View File
@@ -24,58 +24,97 @@ In the initial version all users share the same access level. Role-based access
The default mode when opening the application. The default mode when opening the application.
**Interface list pane (left, collapsible)** **Interface list pane (left, collapsible, resizable)**
- Displays all interfaces saved on the server, grouped into a tree by folder. - Displays all interfaces saved on the server.
- Right-click on an interface: options to open in Edit mode or clone it. - Right-click on an interface: options to open in Edit mode or clone it.
- "New interface" button opens Edit mode with a blank canvas. - "New interface" button opens Edit mode with a blank canvas.
- "Import" option loads an interface from a local XML file. - The pane width can be adjusted by dragging the resize handle on its right edge.
**Tabs**
- **HMI tab**: the live widget canvas (described below).
- **Plot tab**: a dedicated live multi-signal plot panel (see §3.3).
- **Info tab**: signal info and metadata display for the last right-clicked signal.
**HMI canvas (center)** **HMI canvas (center)**
- Renders the selected interface as a live, interactive panel. - Renders the selected interface as a live, interactive panel.
- Widgets display real-time data; controls (set-value, buttons) are active. - Widgets display real-time data; controls (set-value, buttons) are active.
- No drag, resize, or layout operations are possible in this mode. - No drag, resize, or layout operations are possible in this mode.
- Right-clicking any widget opens a context menu: - Right-clicking any widget opens a context menu:
- **Signal info** — floating window showing DS name, type, unit, range, current value and timestamp. - **Signal info** — switches to the Info tab showing DS name, type, unit, range, current value and timestamp.
- **Copy signal name** — copies the signal identifier to the clipboard. - **Copy signal name** — copies the signal identifier to the clipboard.
- **Export data to CSV** — downloads buffered/historical data for the signal(s) used by the widget. - **Export data to CSV** — downloads buffered data for the signal(s) used by the widget.
- **Plot** — adds the signal(s) to the Plot tab.
**Top toolbar** **Top toolbar**
- Show/hide interface list pane. - Show/hide interface list pane.
- Time selector: navigate to a past timestamp (enabled only when the server has archive access for all signals on the canvas). - **⏱ History** button: toggle historical time navigation bar.
- "Live" button: return to real-time data after historical navigation. - Zoom control (A / % / A+): adjust the UI scale (see §3.4).
- Edit button: switch to Edit mode for the current interface.
- Connection status indicator.
**Historical time navigation bar** (shown when History is active)
- Date/time range pickers (start and end).
- **Load** button fetches archive data and replaces live data in all widgets.
- **Live** button discards archive data and resumes real-time streaming.
- Status shown on plot widgets: "Loading history…", "No archive data for this range", "Archive unavailable".
### 3.2 Edit Mode ### 3.2 Edit Mode
Activated via the "New interface" button or by right-clicking an existing interface. Activated via the "New interface" button or by clicking Edit in the toolbar.
**Signal tree pane (left, resizable and collapsible)** **Signal tree pane (left, resizable and collapsible)**
- Shows all signals known to each connected data source. - Shows all signals known to each connected data source.
- Sources are shown as top-level nodes; signals are nested within. - Sources are shown as top-level nodes; signals are nested within.
- User can add custom entries: - User can add custom entries:
- For EPICS: manually enter a PV name. - For EPICS: manually enter a PV name.
- For Synthetic: define a new synthetic signal (see §5.2). - For Synthetic: define a new synthetic signal via the Synthetic Wizard (see §5.2).
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
- Filter/search box to narrow the list. - Filter/search box to narrow the list.
- Synthetic signals show an edit (✎) button to reopen the wizard.
**Widget canvas (center)** **Widget canvas (center)**
- Free-form canvas where widgets can be placed at arbitrary pixel positions. - Free-form canvas where widgets can be placed at arbitrary pixel positions.
- Background grid with optional snap-to-grid. - Background grid with snap-to-grid.
**Properties pane (right, collapsible)** **Properties pane (right, resizable and collapsible)**
- Appears when one or more widgets are selected. - Appears when one or more widgets are selected.
- Displays and edits all options for the selected widget (see §4). - Displays and edits all options for the selected widget (see §4).
- Width is adjustable by dragging the resize handle on its left edge.
**Top toolbar** **Top toolbar**
- Show/hide signal pane. - Show/hide signal pane / properties pane.
- Show/hide properties pane.
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z). - Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
- Import / Export interface to/from local XML file.
- Save interface to server. - Save interface to server.
- Load interface from server. - Close (return to View mode).
- Export interface to local XML file. - Zoom control (A / % / A+).
- Import interface from local XML file.
- "Add text" tool — inserts a static text label. ### 3.3 Plot Tab (Live Multi-Signal Plot)
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
- "Add link" tool — inserts a button that opens another interface. A dedicated panel for live time-series plotting of any signals, independent of the interface layout.
**Adding signals:** right-click any widget in the HMI tab → **Plot**. The signal is added to the Plot tab immediately.
**Time window selector:** 10s / 30s / 1m / 5m / 15m / 1h buttons control how much history is displayed.
**Per-signal legend:**
- Color swatch and signal name.
- Statistics table: Last / Min / Max / Mean over the current window.
- ✎ button opens an inline style editor:
- **Color**: color picker.
- **Width**: line width (none, 1, 1.5, 2, 3 px).
- **Line**: solid / dashed / dotted.
- **Markers**: none, S (3 px), M (5 px), L (8 px).
- ✕ button removes the signal from the plot.
**Chart area:** rendered with uPlot; auto-scaled Y axis; time axis tracks the rolling window in real time.
### 3.4 UI Zoom
The toolbar in both modes contains a zoom control (**A** / **nn%** / **A+**) that adjusts the base font size of the entire UI:
- 11 zoom steps from 50% to 250% (50, 60, 75, 85, 100, 115, 130, 150, 175, 200, 250%).
- The selected zoom level is persisted in `localStorage` and restored on the next page load.
- At 100%, the base font size adapts to viewport height via `clamp(13px, 1.5vh, 18px)`, making the UI naturally usable on 4K displays without any manual adjustment.
--- ---
@@ -83,7 +122,7 @@ Activated via the "New interface" button or by right-clicking an existing interf
### 4.1 Creating Widgets ### 4.1 Creating Widgets
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type (iconised). The user selects one and the widget is placed at the drop location with default size. Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type. The user selects one and the widget is placed at the drop location with default size.
### 4.2 Selecting Widgets ### 4.2 Selecting Widgets
@@ -104,8 +143,7 @@ When multiple widgets are selected:
- An align/distribute toolbar appears above the canvas with: - An align/distribute toolbar appears above the canvas with:
- Align left / center horizontal / right. - Align left / center horizontal / right.
- Align top / center vertical / bottom. - Align top / center vertical / bottom.
- Distribute evenly — by center spacing (horizontal/vertical). - Distribute evenly (horizontal/vertical).
- Distribute evenly — by gap size (horizontal/vertical).
### 4.4 Widget Catalogue ### 4.4 Widget Catalogue
@@ -115,7 +153,7 @@ When multiple widgets are selected:
| Gauge | numeric scalar | Circular or arc gauge with configurable range | | Gauge | numeric scalar | Circular or arc gauge with configurable range |
| Vertical bar | numeric scalar | Vertical level indicator | | Vertical bar | numeric scalar | Vertical level indicator |
| Horizontal bar | numeric scalar | Horizontal level indicator | | Horizontal bar | numeric scalar | Horizontal level indicator |
| Set value | numeric or string, writable | Shows `name: [input field] current_value unit` + Set button | | Set value | numeric, string, or enum; writable | Input field or enum dropdown + Set button |
| LED | boolean / numeric | Coloured indicator with configurable condition and label | | LED | boolean / numeric | Coloured indicator with configurable condition and label |
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions | | Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
| Button | writable | Sends a fixed value or command on click | | Button | writable | Sends a fixed value or command on click |
@@ -140,45 +178,62 @@ When multiple widgets are selected:
Common to all: Common to all:
- Label text, font size, text colour. - Label text, font size, text colour.
- Position (X, Y) and size (W, H) — editable numerically. - Position (X, Y) and size (W, H) — editable numerically.
- Data source and signal name (read-only after creation; reassignable via drag). - Data source and signal name.
Per type: Per type:
- **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label. - **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label.
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states. - **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states, label.
- **Plot**: plot sub-type selector, Y-axis range (auto or manual), time window duration, legend position, colour per signal, line style. - **Plot**: plot sub-type selector, Y-axis range (auto or manual min/max), time window duration, legend position (top / bottom / none), value format string.
- **Set value**: input type (numeric / string / enum), confirmation prompt toggle. - **Set value**: no special options — enum mode is detected automatically from signal metadata.
- **Link**: target interface name. - **Link**: target interface name.
### 4.6 Set-value Widget — Enum Mode
When the signal's metadata reports enum strings (e.g. EPICS mbbi/mbbo records):
- The input field is replaced by a `<select>` dropdown showing all enum options.
- The current live value is shown as the pre-selected option (display only).
- The user selects a value from the dropdown, then clicks **Set** to write it.
- The Set button is always required; changing the dropdown does not write immediately.
--- ---
## 5. Data Sources ## 5. Data Sources
### 5.1 EPICS ### 5.1 EPICS
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA). - Connects to an EPICS environment via Channel Access (CA).
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range (DRVL/DRVH, LOPR/HOPR), alarm limits, enum strings (for mbbi/mbbo records), read/write mode, acquisition mode. - On connect, retrieves full metadata from the PV name: data type, engineering units, display range, alarm limits, enum strings (for mbbi/mbbo records), read/write mode.
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable. - Uses `ca_add_event` monitors — no polling.
- Attempts to enumerate available PV names from the IOC (e.g. via PV lists or Channel Finder). Unknown PVs can still be manually added by the user. - When an EPICS Archive Appliance is configured, the server can satisfy historical data requests from the toolbar time navigator.
- When an EPICS Archive Appliance or Channel Archiver is configured, the server can satisfy historical data requests.
### 5.2 Synthetic ### 5.2 Synthetic Signals
A signal defined by composing one or more input signals (from any data source) through a chain of processing functions. A signal defined by composing one or more input signals through a chain of processing nodes.
**Built-in processing functions (non-exhaustive):** **New Synthetic Signal Wizard:**
- Arithmetic: gain, offset, add, subtract, multiply, divide.
- Signal processing: moving average (N samples or time window), RMS, bandpass filter (IIR/FIR), lowpass / highpass filter, derivative, integral.
- FFT, inverse FFT.
- Peak detection, threshold crossing.
- Custom formula: inline expression (`a * sin(b) + c`).
- Lua script block: arbitrary Lua code with access to input values and state.
**User workflow:**
1. Click "New synthetic signal" in the signal tree. 1. Click "New synthetic signal" in the signal tree.
2. Name the signal and choose input signals. 2. Name the signal and optionally set a unit.
3. Build a processing pipeline by chaining function blocks (UI similar to a node graph or an ordered list). 3. Add processing nodes from the node type dropdown and connect them.
4. Optionally write a Lua snippet for custom logic. 4. Configure each node's parameters inline.
5. The synthetic signal appears in the tree and can be used like any other signal. 5. Click Create — the signal appears in the tree and updates live.
The wizard dialog is resizable (drag its corner) and defaults to 600 px wide to accommodate the Lua editor.
**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the wizard.
**Built-in node types:**
| Node | Parameters | Description |
|------|------------|-------------|
| Source | DS, signal name | Input from any data source |
| Gain | factor | Multiply by constant |
| Offset | value | Add constant |
| Moving average | window (samples) | Rolling mean |
| Low-pass filter | frequency (Hz), order (18) | IIR low-pass; correct for non-uniform sample rates |
| Formula | expression | Inline math (variables: `a`, `b`, …) |
| Lua script | script | Arbitrary Lua 5.1 with persistent state table |
**Lua editor:** the Lua node parameter uses a code editor with syntax highlighting (keywords, strings, comments, numbers rendered in distinct colours). The editor is a full-height multi-line input that grows with the dialog.
--- ---
@@ -192,11 +247,13 @@ A signal defined by composing one or more input signals (from any data source) t
## 7. Historical Data Navigation ## 7. Historical Data Navigation
When the server has archive access for all signals on the current canvas: When the server has archive access configured:
- The top toolbar shows a date/time picker. - The **⏱ History** button in the View mode toolbar reveals a time range bar.
- Selecting a past time replays data from the archive into all widgets. - Users set a start and end date/time and click **Load**.
- The "Live" button resumes real-time streaming. - Plot widgets fetch and display the archived data for the selected range.
- Widgets that support time-axis (plots) show the historical range; point-value widgets show the value at the selected time. - Point-value widgets (text view, gauge, bar, LED) show the value at the end of the selected range.
- The **Live** button resumes real-time streaming.
- Status overlays on plot widgets indicate loading, empty, or error states.
--- ---
@@ -210,4 +267,5 @@ When the server has archive access for all signals on the current canvas:
| Concurrent clients | ≥ 20 simultaneous browser clients | | Concurrent clients | ≥ 20 simultaneous browser clients |
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate | | Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
| Frontend responsiveness | 60 fps canvas rendering during live updates | | Frontend responsiveness | 60 fps canvas rendering during live updates |
| Screen DPI | Frontend adapts to device pixel ratio | | Screen DPI | UI scales with viewport height; manual zoom 50250% |
| Plot buffer | 200,000 samples per signal retained in-browser |
+158 -90
View File
@@ -20,31 +20,31 @@
| Package | Purpose | | Package | Purpose |
| ---------------------------- | ------------------------------------------------------ | | ---------------------------- | ------------------------------------------------------ |
| `nhooyr.io/websocket` | WebSocket server (no CGo, more ergonomic than gorilla) | | `nhooyr.io/websocket` | WebSocket server (no CGo, more ergonomic than gorilla) |
| `go-epics/ca` or CGo wrapper | EPICS Channel Access | | CGo wrapper to `libca` | EPICS Channel Access |
| `yuin/gopher-lua` | Lua 5.1 runtime for synthetic signals | | `yuin/gopher-lua` | Lua 5.1 runtime for synthetic signals |
| `gonum.org/v1/gonum` | DSP and math functions (FFT, filters) | | `BurntSushi/toml` | TOML config parsing |
| `encoding/xml` (stdlib) | Interface file serialisation | | `encoding/xml` (stdlib) | Interface file serialisation |
| `net/http` (stdlib) | HTTP server and static file serving | | `net/http` (stdlib) | HTTP server and static file serving |
### 1.2 Frontend — Svelte + TypeScript ### 1.2 Frontend — Preact + TypeScript
**Rationale:** **Rationale:**
- Svelte compiles to vanilla JS with no virtual DOM, giving the smallest bundle and lowest runtime overhead — essential for the 60 fps reactive feel required. - Preact is a 3 kB React-compatible virtual DOM library — small bundle, fast diffing, no extra framework overhead.
- Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival. - esbuild (invoked via its Go API) bundles the TypeScript/TSX source in milliseconds with no Node.js or npm dependency at build time.
- TypeScript catches signal subscription and widget property type errors at build time. - TypeScript catches signal subscription and widget property type errors at build time.
- All vendor JS/CSS is checked into `web/vendor/` so the repo builds without internet access.
**Key dependencies:** **Key dependencies (vendored):**
| Package | Purpose | | Package | Purpose |
| ----------------- | ------------------------------------------------------------------------------ | | ---------------- | --------------------------------------------------------------- |
| `svelte` + `vite` | Framework and build toolchain | | `preact` 10 | Virtual DOM UI framework |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) | | `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots | | `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
| `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) | | `uplot.css` | uPlot default stylesheet |
| `svelte-konva` | Svelte bindings for Konva |
**Intentionally excluded:** React, Vue, WebGPU, jQuery. **Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
--- ---
@@ -52,36 +52,49 @@
``` ```
uopi/ uopi/
├── cmd/uopi/ # main package — CLI flags, wiring ├── cmd/uopi/ # main package — CLI flags, wiring
├── internal/ ├── internal/
│ ├── server/ # HTTP + WebSocket handlers │ ├── server/ # HTTP + WebSocket handlers
│ ├── broker/ # signal fan-out to clients │ ├── broker/ # signal fan-out to clients
│ ├── datasource/ │ ├── datasource/
│ │ ├── iface.go # DataSource interface │ │ ├── iface.go # DataSource interface
│ │ ├── epics/ # EPICS CA/PVA implementation │ │ ├── epics/ # EPICS CA implementation (CGo)
│ │ └── synthetic/ # synthetic signal engine │ │ └── synthetic/ # synthetic signal engine + DSP bridge
│ ├── lua/ # Lua sandbox helpers │ ├── dsp/ # DSP node implementations (lowpass, MA, etc.)
│ ├── dsp/ # DSP functions (wraps gonum + custom) │ ├── storage/ # interface XML read/write
── storage/ # interface XML read/write ── api/ # REST handler functions
│ └── api/ # REST handler functions ├── web/
├── web/ # Svelte source │ ├── embed.go # //go:embed dist — exports FS to Go
│ ├── src/ │ ├── src/ # TypeScript/TSX source (Preact)
│ │ ├── lib/ │ │ ├── lib/
│ │ │ ├── ws.ts # WebSocket client + subscription manager │ │ │ ├── ws.ts # WebSocket client + subscription manager
│ │ │ ├── stores.ts # Svelte stores for signal values │ │ │ ├── stores.ts # signal value + metadata stores
│ │ │ ├── widgets/ # one .svelte file per widget type │ │ │ ├── types.ts # shared TypeScript interfaces
│ │ │ ── editor/ # edit-mode canvas, toolbar, properties pane │ │ │ ── xml.ts # interface XML parse/serialize
│ │ ├── routes/ │ │ │ └── format.ts # value formatting helpers
│ │ │ ├── +page.svelte # view mode │ │ ├── widgets/ # one .tsx file per widget type
│ │ │ └── edit/+page.svelte # edit mode │ │ ├── App.tsx # top-level component, mode routing
│ │ ── app.html │ │ ── ViewMode.tsx # view mode layout + tabs
│ ├── package.json │ ├── EditMode.tsx # edit mode layout + toolbar
└── vite.config.ts │ ├── Canvas.tsx # live HMI canvas (view mode)
├── docs/ # specs, work plan │ │ ├── EditCanvas.tsx # free-form widget editor canvas
│ │ ├── PlotPanel.tsx # live plot side-panel (Plot tab)
│ │ ├── InfoPanel.tsx # signal info side-panel
│ │ ├── ZoomControl.tsx # UI zoom A-/A+ control
│ │ ├── SyntheticWizard.tsx # new synthetic signal dialog
│ │ ├── SyntheticEditor.tsx # edit existing synthetic signal
│ │ ├── LuaEditor.tsx # Lua code editor with syntax highlight
│ │ └── styles.css # all component styles
│ ├── vendor/ # vendored JS/CSS (preact, uplot, echarts)
│ └── dist/ # built frontend — generated, not committed
├── tools/buildfrontend/ # esbuild Go API bundler (go generate)
├── docs/ # specs, work plan
├── CLAUDE.md ├── CLAUDE.md
└── README.md └── README.md
``` ```
The embed package lives at `web/embed.go` (not in `cmd/`) because `//go:embed` paths cannot use `..`.
--- ---
## 3. Backend Architecture ## 3. Backend Architecture
@@ -187,8 +200,8 @@ Base path: `/api/v1`
### 3.5 EPICS Data Source ### 3.5 EPICS Data Source
- Uses CGo bindings to EPICS Base `libca` (Channel Access). PVAccess support via `p4p` C library or a pure-Go PVA client if available. - Uses CGo bindings to EPICS Base `libca` (Channel Access).
- Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it. - Channel connections are lazy: connected on first Subscribe, disconnected when the broker releases it.
- On connect, a `ca_get` retrieves full DBR_CTRL metadata (units, limits, enum strings). - On connect, a `ca_get` retrieves full DBR_CTRL metadata (units, limits, enum strings).
- `ca_add_event` sets up the monitor. Update callbacks push into the broker's raw channel. - `ca_add_event` sets up the monitor. Update callbacks push into the broker's raw channel.
- Multiple PV subscriptions share one CA context per data source instance (thread-safe with `ca_attach_context`). - Multiple PV subscriptions share one CA context per data source instance (thread-safe with `ca_attach_context`).
@@ -198,9 +211,24 @@ Base path: `/api/v1`
- Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes. - Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes.
- Processing nodes are re-evaluated whenever any upstream signal emits a new value. - Processing nodes are re-evaluated whenever any upstream signal emits a new value.
- Built-in node types implemented on top of `gonum/dsp` and custom code. - Definitions are stored in a configurable JSON/TOML file alongside server configuration.
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table. - The `dsp_bridge.go` file maps node type names to `dsp.Node` implementations.
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
**Built-in node types:**
| Node type | Parameters | Description |
| ------------ | ----------------------------------- | ------------------------------------------------ |
| `source` | `ds`, `name` | Reads a signal from any data source |
| `gain` | `factor` | Multiplies by a constant |
| `offset` | `value` | Adds a constant |
| `moving_avg` | `window` (samples) | Rolling mean |
| `lowpass` | `freq` (Hz), `order` (18) | Cascaded IIR Butterworth-style low-pass filter |
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
**Lua node:** Receives `inputs` table (indexed by signal name) and a persistent `state` table across calls. The `os`, `io`, `package`, and `debug` libraries are disabled.
### 3.7 Interface Storage ### 3.7 Interface Storage
@@ -215,6 +243,7 @@ Interfaces are stored as XML files in a configurable directory on the server.
<option key="yMin" value="auto"/> <option key="yMin" value="auto"/>
<option key="yMax" value="auto"/> <option key="yMax" value="auto"/>
<option key="timeWindow" value="60"/> <option key="timeWindow" value="60"/>
<option key="legend" value="bottom"/>
</widget> </widget>
<widget id="w2" type="led" x="50" y="50" w="80" h="80"> <widget id="w2" type="led" x="50" y="50" w="80" h="80">
<signal ds="epics" name="EPICS:STATUS"/> <signal ds="epics" name="EPICS:STATUS"/>
@@ -230,47 +259,79 @@ Interfaces are stored as XML files in a configurable directory on the server.
## 4. Frontend Architecture ## 4. Frontend Architecture
### 4.1 WebSocket Client (`ws.ts`) ### 4.1 WebSocket Client (`lib/ws.ts`)
- Singleton WebSocket connection, reconnects with exponential back-off. - Singleton WebSocket connection, reconnects with exponential back-off.
- Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message. - Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message.
- Incoming updates are dispatched to signal stores. - Incoming updates are dispatched to per-signal stores.
- `wsClient.history(sig, start, end, maxPoints)` returns a Promise resolving to timestamped point arrays.
### 4.2 Signal Stores (`stores.ts`) ### 4.2 Signal Stores (`lib/stores.ts`)
```typescript ```typescript
// One writable store per subscribed signal // One nanostores atom per subscribed signal
const signalStores = new Map<string, Writable<SignalValue>>(); const signalStores = new Map<string, SignalStore>();
function getStore(signal: string): Readable<SignalValue> { ... } export function getSignalStore(ref: SignalRef): SignalStore { ... }
export function getMetaStore(ref: SignalRef): MetaStore { ... }
``` ```
Widgets import `getStore(signalName)` and bind to it reactively. Svelte's fine-grained reactivity ensures only the relevant widgets re-render on each update. Widgets subscribe to stores directly; store updates trigger re-renders only in the consuming component.
### 4.3 Edit Mode Canvas ### 4.3 Edit Mode Canvas (`EditCanvas.tsx`)
The edit-mode canvas is implemented with **Konva.js** via `svelte-konva`: The edit canvas is a free-form HTML div with absolutely positioned widget components:
- Each widget is a Konva Group containing its visual elements. - Each widget renders as an absolutely positioned `<div>` at `(x, y)` with `(w, h)` dimensions.
- A `Transformer` node provides resize handles and enforces minimum sizes. - Selection shows a CSS-outlined bounding box with 8 resize handles rendered as small squares.
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from `stage.getPointerPosition()`. - Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from the drop event offset.
- Undo/redo uses a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100). - Undo/redo uses an array of past interface snapshots (max depth 50).
- Align/distribute operations compute target positions geometrically and generate a single grouped undo entry. - Align/distribute operations compute target positions geometrically and generate a single undo entry.
- Multi-select via Ctrl+click or rubber-band area select.
### 4.4 Widget Rendering in View Mode ### 4.4 Widget Rendering in View Mode (`Canvas.tsx`)
In view mode the Konva canvas is replaced with a lightweight SVG/HTML layer. Each widget is a Svelte component that: View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
1. Subscribes to its signal store(s) in `onMount`. - Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
2. Receives reactive updates and re-renders only its own DOM subtree. - uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser) manage their own canvas elements inside their widget component.
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own canvas elements inside the Svelte component. ### 4.5 Plot Panel (`PlotPanel.tsx`)
### 4.5 DPI Adaptation A live multi-signal plot panel accessible from the "Plot" tab in View mode:
- CSS uses `rem` units throughout for text. - Signals are added by right-clicking any widget and choosing "Plot".
- Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed. - Supports configurable time window (10s to 1h).
- Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`. - Per-signal style editor: color picker, line width (0 = hidden), line dash (solid/dashed/dotted), marker size (none/S/M/L).
- Statistics table per signal: last, min, max, mean over the current time window.
- Uses a `requestAnimationFrame` loop limited to ≤1 redraw/second when data is not changing.
- Chart fills its container; `ResizeObserver` keeps the uPlot canvas sized correctly.
### 4.6 Resizable Panels
Both edit and view modes support mouse-drag panel resizing:
- **View mode**: drag handle between the interface list pane and the main content area.
- **Edit mode**: drag handles on both sides of the central canvas (signal tree ↔ canvas, canvas ↔ properties pane).
- Handle width: 5 px, cursor changes to `ew-resize` on hover.
- Minimum panel widths enforced to prevent collapse below usable size.
### 4.7 HiDPI / Zoom Support
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
- **ZoomControl** (A / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
### 4.8 Lua Editor (`LuaEditor.tsx`)
A syntax-highlighted code editor for Lua scripts in the Synthetic signal wizard:
- Implemented as a `<textarea>` overlaid on a `<pre>` element; the textarea has `color: transparent; caret-color: #e2e8f0` so only the caret is visible — the `<pre>` provides the coloured text behind it.
- Tokeniser handles: `--` line comments, `"..."` / `'...'` string literals, `[[...]]` long strings, hex and float numeric literals, and all Lua 5.1 keywords.
- Scroll position is synchronised between textarea and pre on every scroll event.
--- ---
@@ -278,34 +339,42 @@ Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own ca
### 5.1 Backend ### 5.1 Backend
```makefile ```bash
# Build static binary (requires EPICS base installed or cross-compiled libca) # Full build (frontend then backend)
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ make all
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
# Run tests # Backend only (frontend must already be built)
go test ./... make backend
# Run a single test # All tests
make test
# Single Go test
go test ./internal/broker/... -run TestFanOut go test ./internal/broker/... -run TestFanOut
# Go vet
go vet ./...
``` ```
EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`. EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`.
### 5.2 Frontend ### 5.2 Frontend
The frontend is built by a Go tool in `tools/buildfrontend/` that invokes the esbuild Go API:
```bash ```bash
cd web make frontend
npm install # or equivalently:
npm run dev # dev server at http://localhost:5173 (proxies /api to backend) go generate ./web/...
npm run build # outputs to web/dist/
npm run check # svelte-check type checking
npm run lint # eslint + prettier
``` ```
### 5.3 Combined Build No Node.js, npm, or any JS build tool is required on the host. The bundler:
- Reads entry point `web/src/main.tsx`.
- Resolves `preact`, `uplot`, and `echarts` imports from `web/vendor/`.
- Outputs `web/dist/main.js` and `web/dist/main.css`.
- Copies `web/dist/index.html`, vendor CSS, and other static assets.
A `Makefile` at the repo root: ### 5.3 Combined Build
```makefile ```makefile
.PHONY: all frontend backend clean .PHONY: all frontend backend clean
@@ -313,20 +382,19 @@ A `Makefile` at the repo root:
all: frontend backend all: frontend backend
frontend: frontend:
cd web && npm ci && npm run build go run ./tools/buildfrontend
backend: frontend backend:
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
test: test:
go test ./... go test ./...
cd web && npm run check
clean: clean:
rm -rf dist/ web/dist/ rm -rf dist/ web/dist/
``` ```
The backend's `//go:embed web/dist` directive picks up the built frontend automatically. The backend's `//go:embed dist` directive in `web/embed.go` picks up the built frontend automatically. `main.go` does `fs.Sub(web.FS, "dist")` to serve a clean root.
--- ---
@@ -346,10 +414,10 @@ archive_url = "" # EPICS Archive Appliance URL
[datasource.synthetic] [datasource.synthetic]
enabled = true enabled = true
definitions_file = "./synthetic.json" definitions_file = "./synthetic.toml"
``` ```
All settings can also be overridden with environment variables: `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`, etc. All settings can also be overridden with `UOPI_*` environment variables (e.g. `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`).
--- ---
@@ -359,11 +427,11 @@ All settings can also be overridden with environment variables: `UOPI_SERVER_LIS
| ------------------ | --------------------------------------------------------------------------------- | | ------------------ | --------------------------------------------------------------------------------- |
| Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle | | Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle |
| Synthetic DSP | Table-driven unit tests against known signal inputs/outputs | | Synthetic DSP | Table-driven unit tests against known signal inputs/outputs |
| Low-pass filter | Unit tests: step response, frequency attenuation vs. analytical expectation |
| Lua sandbox | Unit tests for sandbox isolation and API surface | | Lua sandbox | Unit tests for sandbox isolation and API surface |
| REST API | `httptest` integration tests | | REST API | `httptest` integration tests |
| WebSocket protocol | Integration tests with a test client | | WebSocket protocol | Integration tests with a test client |
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) | | EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
| Frontend | Svelte component tests via `vitest` + `@testing-library/svelte` |
--- ---
@@ -99,6 +99,16 @@ func buildNode(d NodeDef) (dsp.Node, error) {
case "expr": case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
case "lowpass":
order := int(floatParam(p, "order"))
if order < 1 {
order = 1
}
return &dsp.LowPassNode{
Freq: floatParam(p, "freq"),
Order: order,
}, nil
case "lua": case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
+62
View File
@@ -535,6 +535,68 @@ func (p *exprParser) parseFactor() (float64, error) {
return 0, fmt.Errorf("unexpected character %q at position %d", ch, p.pos) return 0, fmt.Errorf("unexpected character %q at position %d", ch, p.pos)
} }
// ── LowPassNode ───────────────────────────────────────────────────────────────
// LowPassNode implements a cascaded first-order IIR (RC) low-pass filter.
// The filter coefficient α is recomputed each sample from the elapsed time so
// it is correct for event-driven (non-fixed-rate) signals.
// For Order > 1 the single-pole stage is cascaded Order times, approximating
// a Butterworth response with -20·Order dB/decade roll-off.
type LowPassNode struct {
Freq float64 // cutoff frequency in Hz (3 dB point)
Order int // number of cascaded stages (18)
}
func (n *LowPassNode) Type() string { return "lowpass" }
func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("lowpass: no inputs")
}
x := inputs[0]
now := time.Now()
order := n.Order
if order < 1 {
order = 1
}
if order > 8 {
order = 8
}
fc := n.Freq
if fc <= 0 {
fc = 1.0
}
prevT, hasT := state["t"].(time.Time)
if !hasT {
// First sample: prime all stage states with the input value.
for i := range order {
state[fmt.Sprintf("y%d", i)] = x
}
state["t"] = now
return x, nil
}
dt := now.Sub(prevT).Seconds()
state["t"] = now
if dt <= 0 {
dt = 1e-9
}
rc := 1.0 / (2.0 * math.Pi * fc)
alpha := dt / (rc + dt)
y := x
for i := range order {
key := fmt.Sprintf("y%d", i)
prev, _ := state[key].(float64)
y = alpha*y + (1-alpha)*prev
state[key] = y
}
return y, nil
}
// ── LuaNode ─────────────────────────────────────────────────────────────────── // ── LuaNode ───────────────────────────────────────────────────────────────────
// LuaNode runs a Lua script in a sandboxed gopher-lua VM. // LuaNode runs a Lua script in a sandboxed gopher-lua VM.
+2
View File
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode'; import ViewMode from './ViewMode';
import EditMode from './EditMode'; import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode'; import FullscreenMode from './FullscreenMode';
import { applyZoom, getStoredZoom } from './ZoomControl';
import type { Interface } from './lib/types'; import type { Interface } from './lib/types';
type AppMode = 'view' | 'edit'; type AppMode = 'view' | 'edit';
@@ -26,6 +27,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
const dpr = window.devicePixelRatio ?? 1; const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr)); document.documentElement.style.setProperty('--dpr', String(dpr));
applyZoom(getStoredZoom());
if (!fsParam) wsClient.connect('/ws'); if (!fsParam) wsClient.connect('/ws');
}, []); }, []);
+27 -1
View File
@@ -7,6 +7,7 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PropertiesPane from './PropertiesPane'; import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
interface Props { interface Props {
initial: Interface | null; initial: Interface | null;
@@ -83,6 +84,27 @@ export default function EditMode({ initial, onDone }: Props) {
const [showInsertMenu, setShowInsertMenu] = useState(false); const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit'); const [helpSection, setHelpSection] = useState('edit');
const [leftW, setLeftW] = useState(220);
const [rightW, setRightW] = useState(260);
function startResize(side: 'left' | 'right') {
return (e: MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = side === 'left' ? leftW : rightW;
function onMove(mv: MouseEvent) {
const dx = mv.clientX - startX;
if (side === 'left') setLeftW(Math.max(140, startW + dx));
else setRightW(Math.max(180, startW - dx));
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
}
function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); } function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); }
// Undo / redo // Undo / redo
@@ -344,6 +366,7 @@ export default function EditMode({ initial, onDone }: Props) {
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<ZoomControl />
<ContextualHelp mode="edit" onOpenManual={openHelp} /> <ContextualHelp mode="edit" onOpenManual={openHelp} />
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button> <button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
<button class="toolbar-btn" onClick={handleImport}>Import</button> <button class="toolbar-btn" onClick={handleImport}>Import</button>
@@ -357,7 +380,8 @@ export default function EditMode({ initial, onDone }: Props) {
{/* ── Three-panel body ── */} {/* ── Three-panel body ── */}
<div class="edit-body"> <div class="edit-body">
<SignalTree /> <SignalTree width={leftW} />
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
<EditCanvas <EditCanvas
iface={iface} iface={iface}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -365,12 +389,14 @@ export default function EditMode({ initial, onDone }: Props) {
onChange={handleWidgetsChange} onChange={handleWidgetsChange}
snapGrid={snapGrid} snapGrid={snapGrid}
/> />
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
<PropertiesPane <PropertiesPane
selected={selectedWidget} selected={selectedWidget}
multiCount={multiSelected ? selectedIds.length : 0} multiCount={multiSelected ? selectedIds.length : 0}
iface={iface} iface={iface}
onChange={handleWidgetChange} onChange={handleWidgetChange}
onIfaceChange={handleIfaceChange} onIfaceChange={handleIfaceChange}
width={rightW}
/> />
</div> </div>
+3 -2
View File
@@ -13,9 +13,10 @@ interface Props {
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onEdit?: (iface?: Interface) => void; onEdit?: (iface?: Interface) => void;
onEditId?: (id: string) => void; onEditId?: (id: string) => void;
width?: number;
} }
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) { export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, width }: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]); const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -66,7 +67,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Pr
} }
return ( return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`}> <aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
<div class="panel-header"> <div class="panel-header">
{!collapsed && <span class="panel-title">Interfaces</span>} {!collapsed && <span class="panel-title">Interfaces</span>}
<button <button
+137
View File
@@ -0,0 +1,137 @@
import { h } from 'preact';
import { useRef } from 'preact/hooks';
// ── Tokenizer ─────────────────────────────────────────────────────────────────
type TT = 'kw' | 'str' | 'cmt' | 'num' | 'text';
const LUA_KEYWORDS = new Set([
'if', 'then', 'else', 'elseif', 'end', 'while', 'do', 'for', 'in',
'return', 'local', 'function', 'not', 'and', 'or', 'nil', 'true',
'false', 'break', 'repeat', 'until',
]);
interface Tok { t: TT; s: string; }
function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_'; }
function isAlNum(c: string) { return isAlpha(c) || (c >= '0' && c <= '9'); }
function isDigit(c: string) { return c >= '0' && c <= '9'; }
function tokenize(src: string): Tok[] {
const out: Tok[] = [];
let i = 0;
while (i < src.length) {
const c = src[i];
// Single-line comment
if (c === '-' && src[i + 1] === '-') {
const end = src.indexOf('\n', i);
const stop = end < 0 ? src.length : end;
out.push({ t: 'cmt', s: src.slice(i, stop) });
i = stop;
continue;
}
// String: "..." or '...'
if (c === '"' || c === "'") {
let j = i + 1;
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue; }
if (src[j] === c) { j++; break; }
j++;
}
out.push({ t: 'str', s: src.slice(i, j) });
i = j;
continue;
}
// Long string [[...]]
if (c === '[' && src[i + 1] === '[') {
const end = src.indexOf(']]', i + 2);
const stop = end < 0 ? src.length : end + 2;
out.push({ t: 'str', s: src.slice(i, stop) });
i = stop;
continue;
}
// Number
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) {
let j = i;
if (src[j] === '0' && (src[j + 1] === 'x' || src[j + 1] === 'X')) {
j += 2;
while (j < src.length && /[0-9a-fA-F]/.test(src[j])) j++;
} else {
while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
if (j < src.length && (src[j] === 'e' || src[j] === 'E')) {
j++;
if (src[j] === '+' || src[j] === '-') j++;
while (j < src.length && isDigit(src[j])) j++;
}
}
out.push({ t: 'num', s: src.slice(i, j) });
i = j;
continue;
}
// Identifier / keyword
if (isAlpha(c)) {
let j = i;
while (j < src.length && isAlNum(src[j])) j++;
const word = src.slice(i, j);
out.push({ t: LUA_KEYWORDS.has(word) ? 'kw' : 'text', s: word });
i = j;
continue;
}
out.push({ t: 'text', s: c });
i++;
}
return out;
}
function renderTokens(src: string) {
return tokenize(src).map((tok, idx) =>
tok.t === 'text'
? <span key={idx}>{tok.s}</span>
: <span key={idx} class={`lua-${tok.t}`}>{tok.s}</span>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
value: string;
onChange: (v: string) => void;
rows?: number;
}
export default function LuaEditor({ value, onChange, rows = 12 }: Props) {
const preRef = useRef<HTMLPreElement>(null);
function syncScroll(e: Event) {
const ta = e.target as HTMLTextAreaElement;
if (preRef.current) {
preRef.current.scrollTop = ta.scrollTop;
preRef.current.scrollLeft = ta.scrollLeft;
}
}
return (
<div class="lua-editor" style={`height:${rows * 1.5}em;`}>
<pre ref={preRef} class="lua-pre" aria-hidden="true">
{renderTokens(value)}
{'\n'}
</pre>
<textarea
class="lua-textarea"
value={value}
spellcheck={false}
autocomplete="off"
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
onScroll={syncScroll}
/>
</div>
);
}
+185 -39
View File
@@ -4,20 +4,31 @@ import uPlot from 'uplot';
import { getSignalStore, getMetaStore } from './lib/stores'; import { getSignalStore, getMetaStore } from './lib/stores';
import type { SignalRef, SignalMeta } from './lib/types'; import type { SignalRef, SignalMeta } from './lib/types';
// ── Public types ──────────────────────────────────────────────────────────────
export interface PlotSignalStyle {
color: string;
lineWidth: number; // px; 0 = no line (markers-only)
lineDash: number[]; // [] = solid, [8,4] = dashed, [2,4] = dotted
markerSize: number; // diameter px; 0 = no markers
}
export interface PlotSignal { export interface PlotSignal {
ref: SignalRef; ref: SignalRef;
color: string; style: PlotSignalStyle;
} }
interface Props { interface Props {
signals: PlotSignal[]; signals: PlotSignal[];
onRemove: (ref: SignalRef) => void; onRemove: (ref: SignalRef) => void;
onStyleChange: (ref: SignalRef, style: PlotSignalStyle) => void;
} }
// ── Constants ─────────────────────────────────────────────────────────────────
interface Buf { ts: number[]; vals: number[]; } interface Buf { ts: number[]; vals: number[]; }
// Large enough to hold a full 1-hour window at ~10 Hz per signal. const RING_MAX = 200_000;
const RING_MAX = 50_000;
const TIME_WINDOWS = [ const TIME_WINDOWS = [
{ label: '10s', secs: 10 }, { label: '10s', secs: 10 },
@@ -28,6 +39,29 @@ const TIME_WINDOWS = [
{ label: '1h', secs: 3600 }, { label: '1h', secs: 3600 },
]; ];
const LINE_WIDTHS: Array<{ label: string; value: number }> = [
{ label: '✕', value: 0 },
{ label: '1', value: 1 },
{ label: '1.5', value: 1.5 },
{ label: '2', value: 2 },
{ label: '3', value: 3 },
];
const LINE_DASHES: Array<{ label: string; value: number[] }> = [
{ label: '━━', value: [] },
{ label: '╍╍', value: [8, 4] },
{ label: '⋯', value: [2, 4] },
];
const MARKER_SIZES: Array<{ label: string; value: number }> = [
{ label: '✕', value: 0 },
{ label: 'S', value: 3 },
{ label: 'M', value: 5 },
{ label: 'L', value: 8 },
];
// ── Pure helpers ──────────────────────────────────────────────────────────────
interface Stat { min: number; max: number; mean: number; last: number; } interface Stat { min: number; max: number; mean: number; last: number; }
function pushSample(buf: Buf, t: number, v: number) { function pushSample(buf: Buf, t: number, v: number) {
@@ -41,6 +75,7 @@ function pushSample(buf: Buf, t: number, v: number) {
function buildData(signals: PlotSignal[], bufs: Map<string, Buf>, windowSec: number): uPlot.AlignedData { function buildData(signals: PlotSignal[], bufs: Map<string, Buf>, windowSec: number): uPlot.AlignedData {
const cutoff = Date.now() / 1000 - windowSec; const cutoff = Date.now() / 1000 - windowSec;
const allTs = new Set<number>(); const allTs = new Set<number>();
for (const s of signals) { for (const s of signals) {
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`); const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
@@ -48,14 +83,23 @@ function buildData(signals: PlotSignal[], bufs: Map<string, Buf>, windowSec: num
} }
const sorted = Array.from(allTs).sort((a, b) => a - b); const sorted = Array.from(allTs).sort((a, b) => a - b);
if (sorted.length === 0) return [[], ...signals.map(() => [])] as uPlot.AlignedData; if (sorted.length === 0) return [[], ...signals.map(() => [])] as uPlot.AlignedData;
return [ return [
sorted, sorted,
...signals.map(s => { ...signals.map(s => {
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`); const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
if (!buf) return sorted.map(() => null); if (!buf || buf.ts.length === 0) return sorted.map(() => null);
const m = new Map<number, number>();
buf.ts.forEach((t, i) => { if (t >= cutoff) m.set(t, buf.vals[i]); }); // Step-hold: carry the most recent sample forward so signals with
return sorted.map(t => m.get(t) ?? null); // different update rates align correctly on the shared time axis.
let bi = 0;
let hold: number | null = null;
while (bi < buf.ts.length && buf.ts[bi] < cutoff) hold = buf.vals[bi++];
return sorted.map(t => {
while (bi < buf.ts.length && buf.ts[bi] <= t) hold = buf.vals[bi++];
return hold;
});
}), }),
] as uPlot.AlignedData; ] as uPlot.AlignedData;
} }
@@ -78,22 +122,47 @@ function fmtN(v: number | undefined | null): string {
return v.toPrecision(4).replace(/\.?0+$/, ''); return v.toPrecision(4).replace(/\.?0+$/, '');
} }
export default function PlotPanel({ signals, onRemove }: Props) { function dashEq(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
function styleKey(s: PlotSignal): string {
return `${s.ref.ds}:${s.ref.name}:${s.style.color}:${s.style.lineWidth}:${s.style.lineDash.join(',')}:${s.style.markerSize}`;
}
function makeSeries(s: PlotSignal): uPlot.Series {
const { color, lineWidth, lineDash, markerSize } = s.style;
return {
label: s.ref.name,
stroke: color,
width: lineWidth,
dash: lineDash.length > 0 ? lineDash : undefined,
points: {
show: markerSize > 0,
size: markerSize,
stroke: color,
fill: color,
},
spanGaps: false,
};
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function PlotPanel({ signals, onRemove, onStyleChange }: Props) {
const chartRef = useRef<HTMLDivElement>(null); const chartRef = useRef<HTMLDivElement>(null);
const [windowSec, setWindowSec] = useState(60); const [windowSec, setWindowSec] = useState(60);
const [stats, setStats] = useState<Array<Stat | null>>([]); const [stats, setStats] = useState<Array<Stat | null>>([]);
const [metas, setMetas] = useState<Map<string, SignalMeta>>(new Map()); const [metas, setMetas] = useState<Map<string, SignalMeta>>(new Map());
const [editingKey, setEditingKey] = useState<string | null>(null);
// Persistent ring buffers survive signal list changes (so we don't lose history)
const bufsRef = useRef<Map<string, Buf>>(new Map()); const bufsRef = useRef<Map<string, Buf>>(new Map());
const signalsKey = signals.map(styleKey).join('|');
const signalsKey = signals.map(s => `${s.ref.ds}:${s.ref.name}:${s.color}`).join(',');
useEffect(() => { useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
const el = chartRef.current; const el = chartRef.current;
// Ensure buffers exist for all current signals
for (const s of signals) { for (const s of signals) {
const key = `${s.ref.ds}\0${s.ref.name}`; const key = `${s.ref.ds}\0${s.ref.name}`;
if (!bufsRef.current.has(key)) bufsRef.current.set(key, { ts: [], vals: [] }); if (!bufsRef.current.has(key)) bufsRef.current.set(key, { ts: [], vals: [] });
@@ -101,23 +170,21 @@ export default function PlotPanel({ signals, onRemove }: Props) {
if (signals.length === 0) return; if (signals.length === 0) return;
// Must be defined BEFORE new uPlot() because the range function closes over it
// and uPlot calls range() synchronously during construction.
let currentWindow = windowSec; let currentWindow = windowSec;
// Create uPlot const uplot = new uPlot(
let uplot = new uPlot(
{ {
width: Math.max(el.clientWidth, 200), width: Math.max(el.clientWidth, 200),
height: Math.max(el.clientHeight, 100), height: Math.max(el.clientHeight, 100),
legend: { show: false }, legend: { show: false },
cursor: { show: true }, cursor: { show: true },
axes: [ axes: [
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, { stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
{ {
stroke: '#64748b', stroke: '#94a3b8',
grid: { stroke: '#2d3748' }, grid: { stroke: '#2d3748' },
ticks: { stroke: '#475569' }, ticks: { stroke: '#475569' },
size: 55,
values: (_u: uPlot, vals: (number | null)[]) => values: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => (v === null || v === undefined) ? '' : fmtN(v)), vals.map(v => (v === null || v === undefined) ? '' : fmtN(v)),
}, },
@@ -125,28 +192,28 @@ export default function PlotPanel({ signals, onRemove }: Props) {
scales: { scales: {
x: { x: {
time: true, time: true,
range: (_u: uPlot, _min: number, _max: number): [number, number] => { range: (): [number, number] => {
const now = Date.now() / 1000; const now = Date.now() / 1000;
return [now - currentWindow, now]; return [now - currentWindow, now];
}, },
}, },
y: { auto: true }, y: {
auto: true,
range: (_u: uPlot, min: number, max: number): [number, number] => {
if (!isFinite(min) || !isFinite(max) || min === max) {
const mid = isFinite(min) ? min : 0;
return [mid - 1, mid + 1];
}
return [min, max];
},
},
}, },
series: [ series: [{}, ...signals.map(makeSeries)],
{},
...signals.map(s => ({
label: s.ref.name,
stroke: s.color,
width: 1.5,
spanGaps: false,
})),
],
}, },
buildData(signals, bufsRef.current, windowSec), buildData(signals, bufsRef.current, windowSec),
el, el,
); );
// Subscriptions to signal stores
const unsubs: Array<() => void> = []; const unsubs: Array<() => void> = [];
let dirty = false; let dirty = false;
@@ -170,13 +237,8 @@ export default function PlotPanel({ signals, onRemove }: Props) {
unsubs.push(unsubM); unsubs.push(unsubM);
}); });
// Expose a setter for the window so the windowSec useEffect can poke it
(el as any)._setWindow = (secs: number) => { currentWindow = secs; dirty = true; }; (el as any)._setWindow = (secs: number) => { currentWindow = secs; dirty = true; };
// RAF update loop — redraws on new data, every ~1 s to advance the time axis,
// or immediately when the chart becomes visible again after being hidden.
// Skips updates while the chart is hidden (display:none → 0×0) to prevent
// uPlot internal state corruption.
let lastDrawTime = 0; let lastDrawTime = 0;
let wasVisible = false; let wasVisible = false;
let rafId = requestAnimationFrame(function tick(ts) { let rafId = requestAnimationFrame(function tick(ts) {
@@ -186,6 +248,10 @@ export default function PlotPanel({ signals, onRemove }: Props) {
if (isVisible && (dirty || becameVisible || ts - lastDrawTime >= 1000)) { if (isVisible && (dirty || becameVisible || ts - lastDrawTime >= 1000)) {
dirty = false; dirty = false;
lastDrawTime = ts; lastDrawTime = ts;
if (becameVisible) {
const nw = el.clientWidth, nh = el.clientHeight;
if (nw > 0 && nh > 0) uplot.setSize({ width: nw, height: nh });
}
uplot.setData(buildData(signals, bufsRef.current, currentWindow)); uplot.setData(buildData(signals, bufsRef.current, currentWindow));
setStats(signals.map(s => setStats(signals.map(s =>
computeStats(bufsRef.current.get(`${s.ref.ds}\0${s.ref.name}`), currentWindow) computeStats(bufsRef.current.get(`${s.ref.ds}\0${s.ref.name}`), currentWindow)
@@ -194,7 +260,6 @@ export default function PlotPanel({ signals, onRemove }: Props) {
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
}); });
// Resize observer
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
const w = el.clientWidth, h = el.clientHeight; const w = el.clientWidth, h = el.clientHeight;
if (w > 0 && h > 0) uplot.setSize({ width: w, height: h }); if (w > 0 && h > 0) uplot.setSize({ width: w, height: h });
@@ -210,11 +275,12 @@ export default function PlotPanel({ signals, onRemove }: Props) {
}; };
}, [signalsKey]); }, [signalsKey]);
// When the window selector changes, poke the running chart
useEffect(() => { useEffect(() => {
if (chartRef.current) (chartRef.current as any)._setWindow?.(windowSec); if (chartRef.current) (chartRef.current as any)._setWindow?.(windowSec);
}, [windowSec]); }, [windowSec]);
// ── Render ──────────────────────────────────────────────────────────────────
return ( return (
<div class="plot-panel"> <div class="plot-panel">
<div class="plot-panel-toolbar"> <div class="plot-panel-toolbar">
@@ -245,15 +311,34 @@ export default function PlotPanel({ signals, onRemove }: Props) {
const st = stats[i] ?? null; const st = stats[i] ?? null;
const meta = metas.get(key); const meta = metas.get(key);
const unit = meta?.unit ?? ''; const unit = meta?.unit ?? '';
const isEditing = editingKey === key;
const { color, lineWidth, lineDash, markerSize } = s.style;
function update(patch: Partial<PlotSignalStyle>) {
onStyleChange(s.ref, { ...s.style, ...patch });
}
return ( return (
<div class="plot-legend-item" key={key}> <div class="plot-legend-item" key={key}>
{/* Header row */}
<div class="plot-legend-hdr"> <div class="plot-legend-hdr">
<span class="plot-legend-swatch" style={`background:${s.color};`} /> <span class="plot-legend-swatch" style={`background:${color};`} />
<span class="plot-legend-name" title={`${s.ref.ds} / ${s.ref.name}`}> <span class="plot-legend-name" title={`${s.ref.ds} / ${s.ref.name}`}>
{s.ref.name} {s.ref.name}
</span> </span>
<button class="plot-legend-rm" onClick={() => onRemove(s.ref)} title="Remove"></button> <button
class={`plot-legend-icon-btn${isEditing ? ' active' : ''}`}
onClick={() => setEditingKey(isEditing ? null : key)}
title="Edit style"
></button>
<button
class="plot-legend-icon-btn"
onClick={() => onRemove(s.ref)}
title="Remove"
></button>
</div> </div>
{/* Stats */}
<table class="plot-stats-tbl"> <table class="plot-stats-tbl">
<tbody> <tbody>
<tr> <tr>
@@ -265,6 +350,67 @@ export default function PlotPanel({ signals, onRemove }: Props) {
<tr><td>Mean</td><td>{st ? fmtN(st.mean) : '—'}</td></tr> <tr><td>Mean</td><td>{st ? fmtN(st.mean) : '—'}</td></tr>
</tbody> </tbody>
</table> </table>
{/* Inline style editor */}
{isEditing && (
<div class="plot-style-editor">
{/* Color */}
<div class="plot-style-row">
<span class="plot-style-label">Color</span>
<input
type="color"
class="plot-style-color"
value={color}
onInput={e => update({ color: (e.target as HTMLInputElement).value })}
/>
</div>
{/* Line width */}
<div class="plot-style-row">
<span class="plot-style-label">Width</span>
<div class="plot-style-btns">
{LINE_WIDTHS.map(lw => (
<button
key={lw.value}
class={`plot-style-btn${lineWidth === lw.value ? ' active' : ''}`}
onClick={() => update({ lineWidth: lw.value })}
title={lw.value === 0 ? 'No line' : `${lw.value}px`}
>{lw.label}</button>
))}
</div>
</div>
{/* Line dash */}
<div class="plot-style-row">
<span class="plot-style-label">Line</span>
<div class="plot-style-btns">
{LINE_DASHES.map(ld => (
<button
key={ld.label}
class={`plot-style-btn${dashEq(lineDash, ld.value) ? ' active' : ''}`}
onClick={() => update({ lineDash: ld.value })}
title={ld.value.length === 0 ? 'Solid' : ld.value[0] > 4 ? 'Dashed' : 'Dotted'}
>{ld.label}</button>
))}
</div>
</div>
{/* Markers */}
<div class="plot-style-row">
<span class="plot-style-label">Markers</span>
<div class="plot-style-btns">
{MARKER_SIZES.map(ms => (
<button
key={ms.value}
class={`plot-style-btn${markerSize === ms.value ? ' active' : ''}`}
onClick={() => update({ markerSize: ms.value })}
title={ms.value === 0 ? 'None' : `Size ${ms.value}`}
>{ms.label}</button>
))}
</div>
</div>
</div>
)}
</div> </div>
); );
})} })}
+3 -2
View File
@@ -8,6 +8,7 @@ interface Props {
iface: Interface; iface: Interface;
onChange: (updated: Widget) => void; onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void; onIfaceChange: (updated: Interface) => void;
width?: number;
} }
function Field({ label, children }: { label: string; children: any }) { function Field({ label, children }: { label: string; children: any }) {
@@ -44,7 +45,7 @@ const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue',
// Widgets with multiple signals // Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']); const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) { export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
function setOpt(key: string, value: string) { function setOpt(key: string, value: string) {
@@ -66,7 +67,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
const w = selected; const w = selected;
return ( return (
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;"> <aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style={`border-left:1px solid #2d3748;border-right:none;${!collapsed && width ? `width:${width}px;min-width:${width}px;` : ''}`}>
<div class="panel-header"> <div class="panel-header">
{!collapsed && <span class="panel-title">Properties</span>} {!collapsed && <span class="panel-title">Properties</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}> <button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
+3 -2
View File
@@ -37,9 +37,10 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) {
interface Props { interface Props {
onDragStart?: (sig: SignalRef) => void; onDragStart?: (sig: SignalRef) => void;
width?: number;
} }
export default function SignalTree({ onDragStart }: Props) { export default function SignalTree({ onDragStart, width }: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [groups, setGroups] = useState<DsGroup[]>([]); const [groups, setGroups] = useState<DsGroup[]>([]);
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom); const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
@@ -211,7 +212,7 @@ export default function SignalTree({ onDragStart }: Props) {
} }
return ( return (
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}> <aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
<div class="panel-header"> <div class="panel-header">
{!collapsed && <span class="panel-title">Signals</span>} {!collapsed && <span class="panel-title">Signals</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}> <button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
+27 -15
View File
@@ -1,22 +1,27 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import LuaEditor from './LuaEditor';
interface NodeParam { interface NodeParam {
label: string; label: string;
key: string; key: string;
type: 'number' | 'text'; type: 'number' | 'text' | 'lua';
default: string; default: string;
} }
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] }, { type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, { type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'derivative', label: 'Derivative', params: [] }, { type: 'lowpass', label: 'Low-pass Filter', params: [
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, { label: 'Order (18)', key: 'order', type: 'number', default: '1' },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] }, ]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] },
]; ];
interface PipelineNode { interface PipelineNode {
@@ -160,12 +165,19 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
{paramDefs.map(pd => ( {paramDefs.map(pd => (
<div key={pd.key} class="wizard-field wizard-field-inline"> <div key={pd.key} class="wizard-field wizard-field-inline">
<label>{pd.label}</label> <label>{pd.label}</label>
<input {pd.type === 'lua' ? (
class="prop-input" <LuaEditor
type={pd.type === 'number' ? 'number' : 'text'} value={String(node.params[pd.key] ?? pd.default)}
value={node.params[pd.key] ?? pd.default} onChange={(v) => setNodeParam(idx, pd.key, v)}
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)} />
/> ) : (
<input
class="prop-input"
type={pd.type === 'number' ? 'number' : 'text'}
value={node.params[pd.key] ?? pd.default}
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)}
/>
)}
</div> </div>
))} ))}
</div> </div>
+24 -12
View File
@@ -1,5 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import LuaEditor from './LuaEditor';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@@ -9,19 +10,23 @@ interface Props {
interface NodeParam { interface NodeParam {
label: string; label: string;
key: string; key: string;
type: 'number' | 'text'; type: 'number' | 'text' | 'lua';
default: string; default: string;
} }
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] }, { type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, { type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] }, { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'derivative', label: 'Derivative', params: [] }, { type: 'lowpass', label: 'Low-pass Filter', params: [
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, { label: 'Order (18)', key: 'order', type: 'number', default: '1' },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] }, ]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] },
]; ];
export default function SyntheticWizard({ onClose, onCreated }: Props) { export default function SyntheticWizard({ onClose, onCreated }: Props) {
@@ -137,9 +142,16 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
{nodeDef.params.map(p => ( {nodeDef.params.map(p => (
<div key={p.key} class="wizard-field"> <div key={p.key} class="wizard-field">
<label>{p.label}</label> <label>{p.label}</label>
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'} {p.type === 'lua' ? (
value={getParam(p.key, p.default)} <LuaEditor
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} /> value={getParam(p.key, p.default)}
onChange={(v) => setParam(p.key, v)}
/>
) : (
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
value={getParam(p.key, p.default)}
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
)}
</div> </div>
))} ))}
+30 -3
View File
@@ -3,12 +3,13 @@ import { useState, useMemo, useEffect } from 'preact/hooks';
import InterfaceList from './InterfaceList'; import InterfaceList from './InterfaceList';
import Canvas from './Canvas'; import Canvas from './Canvas';
import PlotPanel from './PlotPanel'; import PlotPanel from './PlotPanel';
import type { PlotSignal } from './PlotPanel'; import type { PlotSignal, PlotSignalStyle } from './PlotPanel';
import { wsClient } from './lib/ws'; import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml'; import { parseInterface } from './lib/xml';
import type { Interface, SignalRef } from './lib/types'; import type { Interface, SignalRef } from './lib/types';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
interface Props { interface Props {
onEdit?: (iface?: Interface) => void; onEdit?: (iface?: Interface) => void;
@@ -34,6 +35,22 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
const [showTimeNav, setShowTimeNav] = useState(false); const [showTimeNav, setShowTimeNav] = useState(false);
const [viewTab, setViewTab] = useState<ViewTab>('hmi'); const [viewTab, setViewTab] = useState<ViewTab>('hmi');
const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]); const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]);
const [leftW, setLeftW] = useState(220);
function startResize(e: MouseEvent) {
e.preventDefault();
const startX = e.clientX;
const startW = leftW;
function onMove(mv: MouseEvent) {
setLeftW(Math.max(140, startW + mv.clientX - startX));
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); } function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
@@ -100,7 +117,8 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
const already = plotSignals.some(s => `${s.ref.ds}\0${s.ref.name}` === key); const already = plotSignals.some(s => `${s.ref.ds}\0${s.ref.name}` === key);
if (!already) { if (!already) {
const color = PLOT_COLORS[plotSignals.length % PLOT_COLORS.length]; const color = PLOT_COLORS[plotSignals.length % PLOT_COLORS.length];
setPlotSignals(prev => [...prev, { ref, color }]); const style: PlotSignalStyle = { color, lineWidth: 1.5, lineDash: [], markerSize: 5 };
setPlotSignals(prev => [...prev, { ref, style }]);
} }
setViewTab('plot'); setViewTab('plot');
} }
@@ -109,6 +127,12 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
setPlotSignals(prev => prev.filter(s => !(s.ref.ds === ref.ds && s.ref.name === ref.name))); setPlotSignals(prev => prev.filter(s => !(s.ref.ds === ref.ds && s.ref.name === ref.name)));
} }
function updateSignalStyle(ref: SignalRef, style: PlotSignalStyle) {
setPlotSignals(prev => prev.map(s =>
s.ref.ds === ref.ds && s.ref.name === ref.name ? { ...s, style } : s
));
}
const isLive = timeRange === null; const isLive = timeRange === null;
return ( return (
@@ -145,6 +169,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
> >
📖 📖
</button> </button>
<ZoomControl />
<button <button
class="btn-edit" class="btn-edit"
onClick={() => onEdit?.(currentInterface ?? undefined)} onClick={() => onEdit?.(currentInterface ?? undefined)}
@@ -195,6 +220,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
onLoad={handleLoad} onLoad={handleLoad}
onEdit={onEdit} onEdit={onEdit}
onEditId={handleEditById} onEditId={handleEditById}
width={leftW}
onSelect={async (id) => { onSelect={async (id) => {
try { try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`); const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
@@ -206,6 +232,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
} }
}} }}
/> />
<div class="panel-resize-handle" onMouseDown={startResize} />
<div class="view-content-area"> <div class="view-content-area">
{/* Tab bar */} {/* Tab bar */}
@@ -234,7 +261,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
/> />
</div> </div>
<div style={`display:${viewTab === 'plot' ? 'contents' : 'none'}`}> <div style={`display:${viewTab === 'plot' ? 'contents' : 'none'}`}>
<PlotPanel signals={plotSignals} onRemove={removeFromPlot} /> <PlotPanel signals={plotSignals} onRemove={removeFromPlot} onStyleChange={updateSignalStyle} />
</div> </div>
</div> </div>
</div> </div>
+35
View File
@@ -0,0 +1,35 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
const LS_KEY = 'uopi:ui-zoom';
const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5];
export function getStoredZoom(): number {
const v = parseFloat(localStorage.getItem(LS_KEY) ?? '1');
return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1;
}
export function applyZoom(z: number): number {
const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z));
localStorage.setItem(LS_KEY, String(clamped));
document.documentElement.style.fontSize = `${Math.round(16 * clamped)}px`;
return clamped;
}
export default function ZoomControl() {
const [zoom, setZoom] = useState(getStoredZoom);
function step(dir: number) {
const idx = STEPS.findIndex(s => s >= zoom - 0.01);
const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir));
setZoom(applyZoom(STEPS[nextIdx]));
}
return (
<div class="zoom-control" title="UI zoom">
<button class="icon-btn zoom-btn" onClick={() => step(-1)} title="Zoom out (smaller UI)">A</button>
<span class="zoom-pct">{Math.round(zoom * 100)}%</span>
<button class="icon-btn zoom-btn" onClick={() => step(1)} title="Zoom in (larger UI)">A+</button>
</div>
);
}
+201 -9
View File
@@ -5,6 +5,10 @@
padding: 0; padding: 0;
} }
html {
font-size: clamp(13px, 1.5vh, 18px);
}
body { body {
background: #0f1117; background: #0f1117;
color: #e2e8f0; color: #e2e8f0;
@@ -52,7 +56,7 @@ body {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 1rem; padding: 0 1rem;
height: 44px; height: 2.75rem;
background: #1a1f2e; background: #1a1f2e;
border-bottom: 1px solid #2d3748; border-bottom: 1px solid #2d3748;
flex-shrink: 0; flex-shrink: 0;
@@ -167,6 +171,22 @@ body {
min-height: 0; min-height: 0;
} }
/* ── Panel resize handle ─────────────────────────────────────────────────── */
.panel-resize-handle {
width: 5px;
flex-shrink: 0;
background: transparent;
cursor: ew-resize;
position: relative;
z-index: 10;
transition: background 0.15s;
}
.panel-resize-handle:hover,
.panel-resize-handle.dragging {
background: #4a9eff55;
}
/* ── EditMode ──────────────────────────────────────────────────────────── */ /* ── EditMode ──────────────────────────────────────────────────────────── */
.edit-mode { .edit-mode {
@@ -204,7 +224,7 @@ body {
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 0.5rem 0.5rem 0.75rem; padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border-bottom: 1px solid #2d3748; border-bottom: 1px solid #2d3748;
min-height: 40px; min-height: 2.5rem;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -472,7 +492,6 @@ body {
.gauge-svg { .gauge-svg {
width: 100%; width: 100%;
flex: 1; flex: 1;
overflow: visible;
} }
.gauge-value { .gauge-value {
@@ -776,6 +795,12 @@ body {
border-color: #4a9eff; border-color: #4a9eff;
} }
.sv-select {
flex: 1;
width: auto;
cursor: pointer;
}
.sv-current { .sv-current {
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
font-size: 0.85rem; font-size: 0.85rem;
@@ -914,7 +939,7 @@ body {
} }
.chart-area { .chart-area {
overflow: hidden; overflow: visible;
} }
/* uPlot dark overrides */ /* uPlot dark overrides */
@@ -1301,9 +1326,13 @@ body {
background: #1e2535; background: #1e2535;
border: 1px solid #2d3748; border: 1px solid #2d3748;
border-radius: 8px; border-radius: 8px;
width: 420px; width: min(600px, 92vw);
min-width: 380px;
min-height: 440px;
max-width: 95vw; max-width: 95vw;
max-height: 90vh; max-height: 92vh;
resize: both;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
@@ -1374,6 +1403,68 @@ body {
/* ── Synthetic Editor ────────────────────────────────────────────────────── */ /* ── Synthetic Editor ────────────────────────────────────────────────────── */
/* ── Lua editor ────────────────────────────────────────────────────────────── */
.lua-editor {
position: relative;
border: 1px solid #3d4f6e;
border-radius: 4px;
overflow: hidden;
background: #0a0d14;
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
min-height: 6em;
}
.lua-pre,
.lua-textarea {
position: absolute;
inset: 0;
margin: 0;
padding: 6px 8px;
border: none;
outline: none;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
tab-size: 2;
font: inherit;
box-sizing: border-box;
}
.lua-pre {
color: #e2e8f0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.lua-textarea {
color: transparent;
caret-color: #e2e8f0;
background: transparent;
resize: none;
z-index: 1;
width: 100%;
height: 100%;
}
.lua-textarea:focus {
outline: none;
box-shadow: inset 0 0 0 1px #4a9eff;
}
/* Syntax token colours */
.lua-kw { color: #c792ea; font-weight: 600; }
.lua-str { color: #c3e88d; }
.lua-cmt { color: #546e7a; font-style: italic; }
.lua-num { color: #f78c6c; }
/* wizard is already wide enough and resizable — no extra :has() rule needed */
/* ── Synthetic pipeline ─────────────────────────────────────────────────────── */
.synth-pipeline-node { .synth-pipeline-node {
background: #1a2234; background: #1a2234;
border: 1px solid #2d3748; border: 1px solid #2d3748;
@@ -2170,7 +2261,7 @@ kbd {
align-items: center; align-items: center;
gap: 2px; gap: 2px;
padding: 0 0.5rem; padding: 0 0.5rem;
height: 36px; height: 2.25rem;
background: #0f1117; background: #0f1117;
border-bottom: 1px solid #1e293b; border-bottom: 1px solid #1e293b;
flex-shrink: 0; flex-shrink: 0;
@@ -2312,7 +2403,7 @@ kbd {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0 0.75rem; padding: 0 0.75rem;
height: 40px; height: 2.5rem;
background: #0f1117; background: #0f1117;
border-bottom: 1px solid #1e293b; border-bottom: 1px solid #1e293b;
flex-shrink: 0; flex-shrink: 0;
@@ -2350,7 +2441,11 @@ kbd {
} }
.plot-panel-content { .plot-panel-content {
display: contents; display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
} }
.plot-panel-legend { .plot-panel-legend {
@@ -2394,6 +2489,21 @@ kbd {
white-space: nowrap; white-space: nowrap;
} }
.plot-legend-icon-btn {
background: none;
border: none;
color: #475569;
cursor: pointer;
font-size: 0.75rem;
padding: 0 0.15rem;
line-height: 1;
opacity: 0.6;
border-radius: 3px;
}
.plot-legend-icon-btn:hover { opacity: 1; color: #94a3b8; }
.plot-legend-icon-btn.active { opacity: 1; color: #4a9eff; }
/* kept for back-compat — remove button is now .plot-legend-icon-btn */
.plot-legend-rm { .plot-legend-rm {
background: none; background: none;
border: none; border: none;
@@ -2405,6 +2515,60 @@ kbd {
} }
.plot-legend-rm:hover { opacity: 1; color: #f87171; } .plot-legend-rm:hover { opacity: 1; color: #f87171; }
/* ── Per-signal style editor ────────────────────────────────────────────── */
.plot-style-editor {
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px solid #1e293b;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.plot-style-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.plot-style-label {
font-size: 0.68rem;
color: #64748b;
width: 2.8rem;
flex-shrink: 0;
}
.plot-style-color {
width: 28px;
height: 20px;
border: 1px solid #2d3748;
border-radius: 3px;
padding: 0;
cursor: pointer;
background: none;
}
.plot-style-btns {
display: flex;
gap: 2px;
}
.plot-style-btn {
background: #1e293b;
border: 1px solid #2d3748;
color: #64748b;
cursor: pointer;
font-size: 0.68rem;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.4;
min-width: 1.6rem;
text-align: center;
}
.plot-style-btn:hover { color: #cbd5e1; border-color: #475569; }
.plot-style-btn.active { background: #1e3a5f; border-color: #4a9eff; color: #e2e8f0; }
.plot-stats-tbl { .plot-stats-tbl {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -2437,3 +2601,31 @@ kbd {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
} }
/* ── Zoom control ─────────────────────────────────────────────────────────── */
.zoom-control {
display: flex;
align-items: center;
gap: 2px;
}
.zoom-btn {
background: #1e293b;
border: 1px solid #2d3748;
color: #94a3b8;
cursor: pointer;
font-size: 0.75rem;
padding: 2px 7px;
border-radius: 4px;
line-height: 1.4;
}
.zoom-btn:hover { color: #e2e8f0; border-color: #475569; background: #273244; }
.zoom-pct {
font-size: 0.72rem;
color: #64748b;
width: 2.8rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
+13 -10
View File
@@ -15,11 +15,12 @@ function qualityColor(q: string): string {
} }
} }
// Arc: -135deg to +135deg (270deg total) // Arc: 135° (lower-left, 7 o'clock = min) → 45° (lower-right, 5 o'clock = max),
const START_DEG = -135; // sweeping clockwise through the top (12 o'clock). 270° total sweep.
const END_DEG = 135; const START_DEG = 135;
const END_DEG = 45;
const TOTAL_DEG = 270; const TOTAL_DEG = 270;
const cx = 50, cy = 54, r = 38; const cx = 50, cy = 50, r = 38;
function degToRad(deg: number) { return (deg * Math.PI) / 180; } function degToRad(deg: number) { return (deg * Math.PI) / 180; }
function polarToXY(deg: number, radius: number) { function polarToXY(deg: number, radius: number) {
@@ -29,7 +30,9 @@ function polarToXY(deg: number, radius: number) {
function arcPath(startDeg: number, endDeg: number, radius: number): string { function arcPath(startDeg: number, endDeg: number, radius: number): string {
const s = polarToXY(startDeg, radius); const s = polarToXY(startDeg, radius);
const e = polarToXY(endDeg, radius); const e = polarToXY(endDeg, radius);
const largeArc = endDeg - startDeg > 180 ? 1 : 0; // Compute the clockwise angular span so wrap-around works correctly.
const span = ((endDeg - startDeg) % 360 + 360) % 360;
const largeArc = span > 180 ? 1 : 0;
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`; return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
} }
function valueToDeg(v: number, minVal: number, maxVal: number): number { function valueToDeg(v: number, minVal: number, maxVal: number): number {
@@ -91,16 +94,16 @@ export default function Gauge({ widget, onContextMenu }: Props) {
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} /> <span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
<svg viewBox="0 0 100 80" class="gauge-svg"> <svg viewBox="0 0 100 90" class="gauge-svg">
<path d={bgPath} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" /> <path d={bgPath} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
{fillPath && ( {fillPath && (
<path d={fillPath} fill="none" stroke={fc} stroke-width="6" stroke-linecap="round" /> <path d={fillPath} fill="none" stroke={fc} stroke-width="6" stroke-linecap="round" />
)} )}
<circle cx={needlePos.x} cy={needlePos.y} r="3" fill={fc} /> <circle cx={needlePos.x} cy={needlePos.y} r="3" fill={fc} />
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text> <text x={cx} y={cy + 6} text-anchor="middle" class="gauge-value">{displayValue()}</text>
{unit && <text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>} {unit && <text x={cx} y={cy + 17} text-anchor="middle" class="gauge-unit">{unit}</text>}
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text> <text x="14" y="80" text-anchor="middle" class="gauge-range">{minVal}</text>
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text> <text x="86" y="80" text-anchor="middle" class="gauge-range">{maxVal}</text>
</svg> </svg>
{label && <div class="gauge-label">{label}</div>} {label && <div class="gauge-label">{label}</div>}
</div> </div>
+45 -19
View File
@@ -18,7 +18,7 @@ interface SeriesBuffer {
values: number[]; values: number[];
} }
const RING_MAX = 4000; 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'];
const ECHARTS_DARK = { const ECHARTS_DARK = {
@@ -91,6 +91,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical) // windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData { function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]]; if (signals.length === 0) return [[]];
const cutoff = windowSec > 0 ? Date.now() / 1000 - windowSec : -Infinity;
const allTs = new Set<number>(); const allTs = new Set<number>();
buffers.forEach(b => { buffers.forEach(b => {
const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps; const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps;
@@ -100,46 +102,68 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (tsSorted.length === 0) { if (tsSorted.length === 0) {
return [[], ...signals.map(() => [])] as uPlot.AlignedData; return [[], ...signals.map(() => [])] as uPlot.AlignedData;
} }
const tsMap = buffers.map(b => {
const m = new Map<number, number>(); // Step-hold interpolation: carry the most recent sample forward so signals
const pts = windowSec > 0 ? windowedSlice(b, windowSec) : { ts: b.timestamps, vals: b.values }; // with different update rates align correctly on the shared time axis.
pts.ts.forEach((t, i) => m.set(t, pts.vals[i]));
return m;
});
return [ return [
tsSorted, tsSorted,
...tsMap.map(m => tsSorted.map(t => m.get(t) ?? null)), ...buffers.map(b => {
if (b.timestamps.length === 0) return tsSorted.map(() => null);
let bi = 0;
let hold: number | null = null;
while (bi < b.timestamps.length && b.timestamps[bi] < cutoff) {
hold = b.values[bi++];
}
return tsSorted.map(t => {
while (bi < b.timestamps.length && b.timestamps[bi] <= t) {
hold = b.values[bi++];
}
return hold;
});
}),
] as uPlot.AlignedData; ] as uPlot.AlignedData;
} }
function makeUplotOpts(): uPlot.Options { function makeUplotOpts(): uPlot.Options {
const yAxis: uPlot.Axis = { const yAxis: uPlot.Axis = {
stroke: '#64748b', stroke: '#94a3b8',
grid: { stroke: '#2d3748' }, grid: { stroke: '#2d3748' },
ticks: { stroke: '#475569' }, ticks: { stroke: '#475569' },
size: 55,
}; };
if (fmt) { if (fmt) {
yAxis.values = (_u: uPlot, vals: (number | null)[]) => yAxis.values = (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => (v === null || v === undefined) ? '' : formatValue(v, fmt)); vals.map(v => (v === null || v === undefined) ? '' : formatValue(v, fmt));
} }
const scaleYRange = (_u: uPlot, min: number, max: number): [number, number] => {
if (!isFinite(min) || !isFinite(max) || min === max) {
const mid = isFinite(min) ? min : 0;
return [mid - 1, mid + 1];
}
return [min, max];
};
const seriesConf: uPlot.Series[] = [ const seriesConf: uPlot.Series[] = [
{}, {},
...signals.map((s, i) => ({ ...signals.map((s, i) => {
label: s.name, const color = s.color ?? COLORS[i % COLORS.length];
stroke: s.color ?? COLORS[i % COLORS.length], return {
width: 1.5, label: s.name,
})), stroke: color,
width: 1.5,
points: { show: true, size: 5, stroke: color, fill: color },
};
}),
]; ];
return { return {
width: w, width: w,
height: h, height: Math.max(50, h - legendH),
series: seriesConf, series: seriesConf,
legend: { show: showLegend }, legend: { show: showLegend },
axes: [ axes: [
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, { stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
yAxis, yAxis,
], ],
scales: { x: { time: true }, y: scaleY }, scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } },
}; };
} }
@@ -247,6 +271,8 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const el = chartRef.current; const el = chartRef.current;
const w = el.clientWidth || widget.w; const w = el.clientWidth || widget.w;
const h = el.clientHeight || widget.h; const h = el.clientHeight || widget.h;
// Reserve vertical space for the uPlot legend so it isn't clipped.
const legendH = showLegend ? 30 : 0;
const scaleY: uPlot.Scale = {}; const scaleY: uPlot.Scale = {};
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin); if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
@@ -334,7 +360,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const nw = chartRef.current.clientWidth; const nw = chartRef.current.clientWidth;
const nh = chartRef.current.clientHeight; const nh = chartRef.current.clientHeight;
if (nw > 0 && nh > 0) { if (nw > 0 && nh > 0) {
if (uplot) uplot.setSize({ width: nw, height: nh }); if (uplot) uplot.setSize({ width: nw, height: Math.max(50, nh - legendH) });
if (echart) echart.resize(); if (echart) echart.resize();
} }
}); });
@@ -346,7 +372,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (uplot) uplot.destroy(); if (uplot) uplot.destroy();
if (echart) echart.dispose(); if (echart) echart.dispose();
}; };
}, [widget.id, timeRangeKey, plotType, signalsKey]); }, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '']);
return ( return (
<div <div
+64 -21
View File
@@ -1,4 +1,4 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores'; import { getSignalStore, getMetaStore } from '../lib/stores';
import { wsClient } from '../lib/ws'; import { wsClient } from '../lib/ws';
@@ -35,32 +35,40 @@ export default function SetValue({ widget, onContextMenu }: Props) {
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || ''); const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const confirm = widget.options['confirm'] === 'true'; const confirm = widget.options['confirm'] === 'true';
const isNumeric = meta ? meta.type !== 'string' : true; const isNumeric = meta ? meta.type !== 'string' : true;
const isEnum = !!(meta?.enumStrings && meta.enumStrings.length > 0);
const quality = sv.quality; const quality = sv.quality;
function displayValue(): string { function displayValue(): string {
const v = sv.value; const v = sv.value;
if (v === null || v === undefined) return '---'; if (v === null || v === undefined) return '---';
if (isEnum && meta?.enumStrings) {
const idx = typeof v === 'number' ? Math.round(v) : parseInt(String(v), 10);
return meta.enumStrings[idx] ?? String(v);
}
if (typeof v === 'number') { if (typeof v === 'number') {
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v); return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
} }
return String(v); return String(v);
} }
function doWrite(raw: string) {
if (!sigRef || !raw.trim()) return;
const val = isNumeric ? parseFloat(raw) : raw;
wsClient.write(sigRef, val);
setInputValue('');
}
function handleSet() { function handleSet() {
if (!sigRef) return; if (!sigRef) return;
const raw = inputValue.trim(); const raw = inputValue.trim();
if (!raw) return; if (!raw) return;
const doWrite = () => {
const val = isNumeric ? parseFloat(raw) : raw;
wsClient.write(sigRef, val);
setInputValue('');
};
if (confirm) { if (confirm) {
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite(); const display = isEnum && meta?.enumStrings
? meta.enumStrings[parseInt(raw, 10)] ?? raw
: raw;
if (window.confirm(`Set ${label} to ${display}?`)) doWrite(raw);
} else { } else {
doWrite(); doWrite(raw);
} }
} }
@@ -68,6 +76,24 @@ export default function SetValue({ widget, onContextMenu }: Props) {
if (e.key === 'Enter') handleSet(); if (e.key === 'Enter') handleSet();
} }
const currentEnumIdx = isEnum && sv.value !== null
? Math.round(typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)))
: -1;
// Tracks the user's selection in the dropdown before they click Set
const [enumSelected, setEnumSelected] = useState(currentEnumIdx >= 0 ? String(currentEnumIdx) : '0');
function handleEnumSet() {
if (!sigRef) return;
const doEnumWrite = () => { wsClient.write(sigRef, parseFloat(enumSelected)); };
if (confirm) {
const display = meta?.enumStrings?.[parseInt(enumSelected, 10)] ?? enumSelected;
if (window.confirm(`Set ${label} to ${display}?`)) doEnumWrite();
} else {
doEnumWrite();
}
}
return ( return (
<div <div
class="setvalue" class="setvalue"
@@ -76,17 +102,34 @@ export default function SetValue({ widget, onContextMenu }: Props) {
> >
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} /> <span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
<span class="sv-label">{label}:</span> <span class="sv-label">{label}:</span>
<input {isEnum ? (
class="sv-input" <>
type={isNumeric ? 'number' : 'text'} <select
value={inputValue} class="sv-input sv-select"
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)} value={enumSelected}
onKeyDown={handleKeydown} onChange={(e: Event) => setEnumSelected((e.target as HTMLSelectElement).value)}
placeholder="value" >
/> {meta!.enumStrings!.map((s, i) => (
<span class="sv-current">{displayValue()}</span> <option key={i} value={String(i)}>{s}</option>
{unit && <span class="sv-unit">{unit}</span>} ))}
<button class="sv-btn" onClick={handleSet}>Set</button> </select>
<button class="sv-btn" onClick={handleEnumSet}>Set</button>
</>
) : (
<>
<input
class="sv-input"
type={isNumeric ? 'number' : 'text'}
value={inputValue}
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
onKeyDown={handleKeydown}
placeholder="value"
/>
<span class="sv-current">{displayValue()}</span>
{unit && <span class="sv-unit">{unit}</span>}
<button class="sv-btn" onClick={handleSet}>Set</button>
</>
)}
</div> </div>
); );
} }