Improved UI
This commit is contained in:
+112
-54
@@ -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 (1–8) | 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 50–250% |
|
||||||
|
| Plot buffer | 200,000 samples per signal retained in-browser |
|
||||||
|
|||||||
+158
-90
@@ -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` (1–8) | 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 (1–8)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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
@@ -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 (1–8)', 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
@@ -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 (1–8)', 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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user