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.
**Interface list pane (left, collapsible)**
- Displays all interfaces saved on the server, grouped into a tree by folder.
**Interface list pane (left, collapsible, resizable)**
- Displays all interfaces saved on the server.
- Right-click on an interface: options to open in Edit mode or clone it.
- "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)**
- Renders the selected interface as a live, interactive panel.
- Widgets display real-time data; controls (set-value, buttons) are active.
- No drag, resize, or layout operations are possible in this mode.
- 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.
- **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**
- 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).
- "Live" button: return to real-time data after historical navigation.
- **⏱ History** button: toggle historical time navigation bar.
- 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
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)**
- Shows all signals known to each connected data source.
- Sources are shown as top-level nodes; signals are nested within.
- User can add custom entries:
- For EPICS: manually enter a PV name.
- For Synthetic: define a new synthetic signal (see §5.2).
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
- For Synthetic: define a new synthetic signal via the Synthetic Wizard (see §5.2).
- Filter/search box to narrow the list.
- Synthetic signals show an edit (✎) button to reopen the wizard.
**Widget canvas (center)**
- 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.
- 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**
- Show/hide signal pane.
- Show/hide properties pane.
- Show/hide signal pane / properties pane.
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
- Import / Export interface to/from local XML file.
- Save interface to server.
- Load interface from server.
- Export interface to local XML file.
- Import interface from local XML file.
- "Add text" tool — inserts a static text label.
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
- "Add link" tool — inserts a button that opens another interface.
- Close (return to View mode).
- Zoom control (A / % / A+).
### 3.3 Plot Tab (Live Multi-Signal Plot)
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
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
@@ -104,8 +143,7 @@ When multiple widgets are selected:
- An align/distribute toolbar appears above the canvas with:
- Align left / center horizontal / right.
- Align top / center vertical / bottom.
- Distribute evenly — by center spacing (horizontal/vertical).
- Distribute evenly — by gap size (horizontal/vertical).
- Distribute evenly (horizontal/vertical).
### 4.4 Widget Catalogue
@@ -115,7 +153,7 @@ When multiple widgets are selected:
| Gauge | numeric scalar | Circular or arc gauge with configurable range |
| Vertical bar | numeric scalar | Vertical 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 |
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
| Button | writable | Sends a fixed value or command on click |
@@ -140,45 +178,62 @@ When multiple widgets are selected:
Common to all:
- Label text, font size, text colour.
- 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:
- **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.
- **Plot**: plot sub-type selector, Y-axis range (auto or manual), time window duration, legend position, colour per signal, line style.
- **Set value**: input type (numeric / string / enum), confirmation prompt toggle.
- **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 min/max), time window duration, legend position (top / bottom / none), value format string.
- **Set value**: no special options — enum mode is detected automatically from signal metadata.
- **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.1 EPICS
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA).
- 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.
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable.
- 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 or Channel Archiver is configured, the server can satisfy historical data requests.
- Connects to an EPICS environment via Channel Access (CA).
- 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.
- Uses `ca_add_event` monitors — no polling.
- When an EPICS Archive Appliance is configured, the server can satisfy historical data requests from the toolbar time navigator.
### 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):**
- 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:**
**New Synthetic Signal Wizard:**
1. Click "New synthetic signal" in the signal tree.
2. Name the signal and choose input signals.
3. Build a processing pipeline by chaining function blocks (UI similar to a node graph or an ordered list).
4. Optionally write a Lua snippet for custom logic.
5. The synthetic signal appears in the tree and can be used like any other signal.
2. Name the signal and optionally set a unit.
3. Add processing nodes from the node type dropdown and connect them.
4. Configure each node's parameters inline.
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
When the server has archive access for all signals on the current canvas:
- The top toolbar shows a date/time picker.
- Selecting a past time replays data from the archive into all widgets.
- The "Live" button resumes real-time streaming.
- Widgets that support time-axis (plots) show the historical range; point-value widgets show the value at the selected time.
When the server has archive access configured:
- The **⏱ History** button in the View mode toolbar reveals a time range bar.
- Users set a start and end date/time and click **Load**.
- Plot widgets fetch and display the archived data for the selected range.
- 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 |
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
| 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 |
| ---------------------------- | ------------------------------------------------------ |
| `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 |
| `gonum.org/v1/gonum` | DSP and math functions (FFT, filters) |
| `BurntSushi/toml` | TOML config parsing |
| `encoding/xml` (stdlib) | Interface file serialisation |
| `net/http` (stdlib) | HTTP server and static file serving |
### 1.2 Frontend — Svelte + TypeScript
### 1.2 Frontend — Preact + TypeScript
**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.
- Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival.
- Preact is a 3 kB React-compatible virtual DOM library — small bundle, fast diffing, no extra framework overhead.
- 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.
- All vendor JS/CSS is checked into `web/vendor/` so the repo builds without internet access.
**Key dependencies:**
**Key dependencies (vendored):**
| Package | Purpose |
| ----------------- | ------------------------------------------------------------------------------ |
| `svelte` + `vite` | Framework and build toolchain |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
| `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) |
| `svelte-konva` | Svelte bindings for Konva |
| Package | Purpose |
| ---------------- | --------------------------------------------------------------- |
| `preact` 10 | Virtual DOM UI framework |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
| `uplot.css` | uPlot default stylesheet |
**Intentionally excluded:** React, Vue, WebGPU, jQuery.
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
---
@@ -52,36 +52,49 @@
```
uopi/
├── cmd/uopi/ # main package — CLI flags, wiring
├── cmd/uopi/ # main package — CLI flags, wiring
├── internal/
│ ├── server/ # HTTP + WebSocket handlers
│ ├── broker/ # signal fan-out to clients
│ ├── server/ # HTTP + WebSocket handlers
│ ├── broker/ # signal fan-out to clients
│ ├── datasource/
│ │ ├── iface.go # DataSource interface
│ │ ├── epics/ # EPICS CA/PVA implementation
│ │ └── synthetic/ # synthetic signal engine
│ ├── lua/ # Lua sandbox helpers
│ ├── dsp/ # DSP functions (wraps gonum + custom)
── storage/ # interface XML read/write
│ └── api/ # REST handler functions
├── web/ # Svelte source
│ ├── src/
│ │ ├── iface.go # DataSource interface
│ │ ├── epics/ # EPICS CA implementation (CGo)
│ │ └── synthetic/ # synthetic signal engine + DSP bridge
│ ├── dsp/ # DSP node implementations (lowpass, MA, etc.)
│ ├── storage/ # interface XML read/write
── api/ # REST handler functions
├── web/
│ ├── embed.go # //go:embed dist — exports FS to Go
│ ├── src/ # TypeScript/TSX source (Preact)
│ │ ├── lib/
│ │ │ ├── ws.ts # WebSocket client + subscription manager
│ │ │ ├── stores.ts # Svelte stores for signal values
│ │ │ ├── widgets/ # one .svelte file per widget type
│ │ │ ── editor/ # edit-mode canvas, toolbar, properties pane
│ │ ├── routes/
│ │ │ ├── +page.svelte # view mode
│ │ │ └── edit/+page.svelte # edit mode
│ │ ── app.html
│ ├── package.json
└── vite.config.ts
├── docs/ # specs, work plan
│ │ │ ├── stores.ts # signal value + metadata stores
│ │ │ ├── types.ts # shared TypeScript interfaces
│ │ │ ── xml.ts # interface XML parse/serialize
│ │ │ └── format.ts # value formatting helpers
│ │ ├── widgets/ # one .tsx file per widget type
│ │ ├── App.tsx # top-level component, mode routing
│ │ ── ViewMode.tsx # view mode layout + tabs
│ ├── EditMode.tsx # edit mode layout + toolbar
│ ├── Canvas.tsx # live HMI canvas (view mode)
│ │ ├── 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
└── README.md
```
The embed package lives at `web/embed.go` (not in `cmd/`) because `//go:embed` paths cannot use `..`.
---
## 3. Backend Architecture
@@ -187,8 +200,8 @@ Base path: `/api/v1`
### 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.
- Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it.
- Uses CGo bindings to EPICS Base `libca` (Channel Access).
- 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).
- `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`).
@@ -198,9 +211,24 @@ Base path: `/api/v1`
- 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.
- Built-in node types implemented on top of `gonum/dsp` and custom code.
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table.
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
- Definitions are stored in a configurable JSON/TOML file alongside server configuration.
- The `dsp_bridge.go` file maps node type names to `dsp.Node` implementations.
**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
@@ -215,6 +243,7 @@ Interfaces are stored as XML files in a configurable directory on the server.
<option key="yMin" value="auto"/>
<option key="yMax" value="auto"/>
<option key="timeWindow" value="60"/>
<option key="legend" value="bottom"/>
</widget>
<widget id="w2" type="led" x="50" y="50" w="80" h="80">
<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.1 WebSocket Client (`ws.ts`)
### 4.1 WebSocket Client (`lib/ws.ts`)
- Singleton WebSocket connection, reconnects with exponential back-off.
- 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
// One writable store per subscribed signal
const signalStores = new Map<string, Writable<SignalValue>>();
// One nanostores atom per subscribed signal
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.
- A `Transformer` node provides resize handles and enforces minimum sizes.
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from `stage.getPointerPosition()`.
- Undo/redo uses a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100).
- Align/distribute operations compute target positions geometrically and generate a single grouped undo entry.
- Each widget renders as an absolutely positioned `<div>` at `(x, y)` with `(w, h)` dimensions.
- 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 the drop event offset.
- Undo/redo uses an array of past interface snapshots (max depth 50).
- 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`.
2. Receives reactive updates and re-renders only its own DOM subtree.
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
- 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.
- Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed.
- Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`.
- Signals are added by right-clicking any widget and choosing "Plot".
- Supports configurable time window (10s to 1h).
- 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
```makefile
# Build static binary (requires EPICS base installed or cross-compiled libca)
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
```bash
# Full build (frontend then backend)
make all
# Run tests
go test ./...
# Backend only (frontend must already be built)
make backend
# Run a single test
# All tests
make test
# Single Go test
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`.
### 5.2 Frontend
The frontend is built by a Go tool in `tools/buildfrontend/` that invokes the esbuild Go API:
```bash
cd web
npm install
npm run dev # dev server at http://localhost:5173 (proxies /api to backend)
npm run build # outputs to web/dist/
npm run check # svelte-check type checking
npm run lint # eslint + prettier
make frontend
# or equivalently:
go generate ./web/...
```
### 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
.PHONY: all frontend backend clean
@@ -313,20 +382,19 @@ A `Makefile` at the repo root:
all: frontend backend
frontend:
cd web && npm ci && npm run build
go run ./tools/buildfrontend
backend: frontend
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
backend:
CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
test:
go test ./...
cd web && npm run check
go test ./...
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]
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 |
| 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 |
| REST API | `httptest` integration tests |
| WebSocket protocol | Integration tests with a test client |
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
| Frontend | Svelte component tests via `vitest` + `@testing-library/svelte` |
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
---