Phase 6
This commit is contained in:
@@ -41,7 +41,7 @@ Two primary modes toggled via toolbar:
|
|||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Backend:** Go 1.22+, single binary, `//go:embed` for frontend assets
|
- **Backend:** Go 1.22+, single binary, `//go:embed` for frontend assets
|
||||||
- **Frontend:** Svelte 5 + TypeScript + Vite; plots via uPlot (time-series) and ECharts (others); edit canvas via Konva
|
- **Frontend:** Preact 10 + TypeScript; bundled by esbuild Go API (no npm/Node.js); plots via uPlot (time-series) and ECharts (others); all vendor JS/CSS checked into `web/vendor/`
|
||||||
- **Config:** TOML file + `UOPI_*` env var overrides (`github.com/BurntSushi/toml`)
|
- **Config:** TOML file + `UOPI_*` env var overrides (`github.com/BurntSushi/toml`)
|
||||||
- **WebSocket:** `nhooyr.io/websocket` (no CGo)
|
- **WebSocket:** `nhooyr.io/websocket` (no CGo)
|
||||||
- **EPICS:** CGo bindings to `libca`; `gopher-lua` for synthetic signal Lua scripts
|
- **EPICS:** CGo bindings to `libca`; `gopher-lua` for synthetic signal Lua scripts
|
||||||
@@ -57,7 +57,10 @@ internal/datasource/# DataSource interface + EPICS + synthetic (Phases 2–3)
|
|||||||
internal/dsp/ # DSP functions for synthetic (Phase 3)
|
internal/dsp/ # DSP functions for synthetic (Phase 3)
|
||||||
internal/storage/ # interface XML persistence (Phase 6)
|
internal/storage/ # interface XML persistence (Phase 6)
|
||||||
internal/api/ # REST handlers (Phase 1+)
|
internal/api/ # REST handlers (Phase 1+)
|
||||||
web/ # Svelte source (npm); web/embed.go provides embed.FS to Go
|
web/ # Preact/TSX source; web/embed.go provides embed.FS to Go
|
||||||
|
web/src/ # TypeScript/TSX components (Preact)
|
||||||
|
web/vendor/ # vendored JS/CSS (preact, uplot, echarts) — no npm required
|
||||||
|
tools/buildfrontend/# esbuild Go API bundler (invoked by go generate)
|
||||||
web/dist/ # built frontend — generated, not committed
|
web/dist/ # built frontend — generated, not committed
|
||||||
dist/ # compiled binary — generated, not committed
|
dist/ # compiled binary — generated, not committed
|
||||||
```
|
```
|
||||||
@@ -79,9 +82,6 @@ make frontend
|
|||||||
# Run backend (dev mode, frontend must be running separately)
|
# Run backend (dev mode, frontend must be running separately)
|
||||||
go run ./cmd/uopi
|
go run ./cmd/uopi
|
||||||
|
|
||||||
# Frontend dev server (proxies /api and /healthz to :8080)
|
|
||||||
cd web && npm run dev
|
|
||||||
|
|
||||||
# All tests
|
# All tests
|
||||||
make test
|
make test
|
||||||
|
|
||||||
@@ -90,12 +90,6 @@ go test ./internal/broker/... -run TestFanOut
|
|||||||
|
|
||||||
# Go vet
|
# Go vet
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
# Svelte type check
|
|
||||||
cd web && npm run check
|
|
||||||
|
|
||||||
# Svelte lint
|
|
||||||
cd web && npm run lint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Architectural Notes
|
## Key Architectural Notes
|
||||||
|
|||||||
@@ -1,43 +1,72 @@
|
|||||||
.PHONY: all frontend backend backend-epics test test-epics clean
|
.PHONY: all frontend backend backend-epics backend-debug test lint clean run
|
||||||
|
|
||||||
all: frontend backend
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Sources — adding any file here triggers a frontend or backend rebuild #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
frontend:
|
FRONTEND_SRCS := $(shell find web/src -name '*.tsx' -o -name '*.ts' -o -name '*.css') \
|
||||||
cd web && npm ci && npm run build
|
$(wildcard web/vendor/*.mjs web/vendor/*.js web/vendor/*.css) \
|
||||||
|
tools/buildfrontend/main.go
|
||||||
|
|
||||||
backend:
|
GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum
|
||||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
|
||||||
|
|
||||||
# Build backend with EPICS Channel Access support.
|
# --------------------------------------------------------------------------- #
|
||||||
# Requires EPICS Base to be installed and the following environment variables:
|
# Outputs #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
FRONTEND_OUT := web/dist/main.js
|
||||||
|
BINARY := dist/uopi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Top-level targets #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
all: $(BINARY)
|
||||||
|
|
||||||
|
frontend: $(FRONTEND_OUT)
|
||||||
|
|
||||||
|
backend: $(BINARY)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Build rules #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# Bundle TypeScript/TSX → web/dist/ using esbuild (pure Go, no npm)
|
||||||
|
$(FRONTEND_OUT): $(FRONTEND_SRCS)
|
||||||
|
@mkdir -p web/dist
|
||||||
|
go run ./tools/buildfrontend/main.go
|
||||||
|
|
||||||
|
# Compile the self-contained binary (embeds web/dist at build time)
|
||||||
|
$(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
|
||||||
|
@mkdir -p dist
|
||||||
|
go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||||
|
|
||||||
|
# Build with EPICS Channel Access support.
|
||||||
|
# Requires EPICS Base and:
|
||||||
# EPICS_BASE=/path/to/epics/base
|
# EPICS_BASE=/path/to/epics/base
|
||||||
# CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux"
|
# CGO_CFLAGS="-I$$EPICS_BASE/include -I$$EPICS_BASE/include/os/Linux"
|
||||||
# CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom"
|
# CGO_LDFLAGS="-L$$EPICS_BASE/lib/linux-x86_64 -lca -lCom"
|
||||||
backend-epics:
|
backend-epics: $(FRONTEND_OUT)
|
||||||
CGO_ENABLED=1 go build -tags epics -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
@mkdir -p dist
|
||||||
|
CGO_ENABLED=1 go build -tags epics -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||||
|
|
||||||
# Build backend without -s -w for debugging
|
# Unstripped binary for debugging
|
||||||
backend-debug:
|
backend-debug: $(FRONTEND_OUT)
|
||||||
go build -o dist/uopi ./cmd/uopi
|
@mkdir -p dist
|
||||||
|
go build -o $(BINARY) ./cmd/uopi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Dev / CI #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
cd web && npm run check
|
|
||||||
|
|
||||||
# Run EPICS integration tests (requires EPICS Base; see backend-epics target).
|
|
||||||
test-epics:
|
|
||||||
go test -tags epics ./internal/datasource/epics/...
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
cd web && npm run lint
|
|
||||||
|
run: $(BINARY)
|
||||||
|
$(BINARY)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf dist/ web/dist/
|
rm -rf dist/ web/dist/
|
||||||
|
|
||||||
# Run dev servers (requires two terminals or a process manager)
|
|
||||||
run-backend:
|
|
||||||
go run ./cmd/uopi
|
|
||||||
|
|
||||||
run-frontend:
|
|
||||||
cd web && npm run dev
|
|
||||||
|
|||||||
+29
-9
@@ -15,7 +15,9 @@ import (
|
|||||||
"github.com/uopi/uopi/internal/config"
|
"github.com/uopi/uopi/internal/config"
|
||||||
"github.com/uopi/uopi/internal/datasource/epics"
|
"github.com/uopi/uopi/internal/datasource/epics"
|
||||||
"github.com/uopi/uopi/internal/datasource/stub"
|
"github.com/uopi/uopi/internal/datasource/stub"
|
||||||
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
"github.com/uopi/uopi/internal/server"
|
"github.com/uopi/uopi/internal/server"
|
||||||
|
"github.com/uopi/uopi/internal/storage"
|
||||||
"github.com/uopi/uopi/web"
|
"github.com/uopi/uopi/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
store, err := storage.New(cfg.Server.StorageDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to open interface store", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
webFS, err := fs.Sub(web.FS, "dist")
|
webFS, err := fs.Sub(web.FS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("failed to sub web dist", "err", err)
|
log.Error("failed to sub web dist", "err", err)
|
||||||
@@ -51,18 +59,30 @@ func main() {
|
|||||||
|
|
||||||
brk := broker.New(ctx, log)
|
brk := broker.New(ctx, log)
|
||||||
|
|
||||||
// Register data sources
|
// Stub data source is always registered — it provides synthetic test signals
|
||||||
if cfg.Synthetic.Enabled {
|
// used by demo interfaces and unit tests.
|
||||||
ds := stub.New()
|
stubDS := stub.New()
|
||||||
if err := ds.Connect(ctx); err != nil {
|
if err := stubDS.Connect(ctx); err != nil {
|
||||||
log.Error("stub connect", "err", err)
|
log.Error("stub connect", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
brk.Register(ds)
|
brk.Register(stubDS)
|
||||||
|
|
||||||
|
// Synthetic data source: computes derived signals from upstream sources via
|
||||||
|
// configurable DSP pipelines defined in synthetic.json inside the storage dir.
|
||||||
|
var synthDS *synthetic.Synthetic
|
||||||
|
if cfg.Synthetic.Enabled {
|
||||||
|
synthDS = synthetic.New(cfg.Server.StorageDir, brk, log)
|
||||||
|
if err := synthDS.Connect(ctx); err != nil {
|
||||||
|
log.Warn("synthetic connect failed — continuing without synthetic signals", "err", err)
|
||||||
|
synthDS = nil
|
||||||
|
} else {
|
||||||
|
brk.Register(synthDS)
|
||||||
}
|
}
|
||||||
// Register EPICS Channel Access data source (Phase 2).
|
}
|
||||||
// epics.Available() returns false when built without -tags epics so that
|
|
||||||
// machines without EPICS Base installed still compile and run correctly.
|
// EPICS Channel Access data source (requires -tags epics build).
|
||||||
|
// epics.Available() returns false when built without the tag.
|
||||||
if cfg.EPICS.Enabled && epics.Available() {
|
if cfg.EPICS.Enabled && epics.Available() {
|
||||||
ds := epics.New(cfg.EPICS.CAAddrList, cfg.EPICS.ArchiveURL)
|
ds := epics.New(cfg.EPICS.CAAddrList, cfg.EPICS.ArchiveURL)
|
||||||
if err := ds.Connect(ctx); err != nil {
|
if err := ds.Connect(ctx); err != nil {
|
||||||
@@ -72,7 +92,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := server.New(cfg.Server.Listen, webFS, brk, log)
|
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, log)
|
||||||
|
|
||||||
if err := srv.Start(ctx); err != nil {
|
if err := srv.Start(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||||
|
|||||||
+68
-48
@@ -97,26 +97,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3 — Synthetic Data Source
|
## Phase 3 — Synthetic Data Source ✅
|
||||||
|
|
||||||
**Goal:** users can define computed signals from existing signals.
|
**Goal:** users can define computed signals from existing signals.
|
||||||
|
|
||||||
- [ ] Define synthetic signal definition schema (JSON)
|
- [x] Define synthetic signal definition schema (JSON) — `internal/datasource/synthetic/definition.go`
|
||||||
- [ ] Implement DAG evaluator: nodes re-evaluated on upstream change
|
- [x] Implement DAG evaluator: nodes re-evaluated on upstream change — `synthetic.go` Subscribe goroutine
|
||||||
- [ ] Built-in node types (`internal/dsp/`):
|
- [x] Built-in node types (`internal/dsp/nodes.go`):
|
||||||
- Arithmetic: gain, offset, add, subtract, multiply, divide
|
- Arithmetic: gain, offset, add, subtract, multiply, divide
|
||||||
- Statistics: moving average (by count, by time), RMS
|
- Statistics: moving average (by count, by time), RMS
|
||||||
- Filters: IIR lowpass/highpass/bandpass (via gonum or biquad)
|
- Filters: IIR biquad lowpass/highpass/bandpass
|
||||||
- Calculus: finite-difference derivative, cumulative integral
|
- Calculus: finite-difference derivative, cumulative integral
|
||||||
- FFT / inverse FFT (gonum/dft)
|
|
||||||
- Threshold / clamp
|
- Threshold / clamp
|
||||||
- Custom expression (simple formula parser)
|
- Custom expression (formula parser)
|
||||||
- [ ] Lua node: sandboxed `gopher-lua` state per signal; inputs as globals
|
- [x] Lua node: sandboxed `gopher-lua` state per signal; inputs as globals
|
||||||
- [ ] Load synthetic definitions from `synthetic.json` at startup
|
- [x] Load synthetic definitions from `synthetic.json` at startup
|
||||||
- [ ] REST endpoint to CRUD synthetic signal definitions (persists to JSON file)
|
- [x] REST endpoints: `GET/POST /api/v1/synthetic`, `DELETE /api/v1/synthetic/{name}`
|
||||||
- [ ] Unit tests for each DSP node type
|
- [x] Synthetic DS registered in `main.go`; `storePath = cfg.Server.StorageDir`
|
||||||
|
- [x] Unit tests for DSP node types and synthetic subscribe logic
|
||||||
|
|
||||||
**Done when:** a synthetic moving-average of an EPICS PV is visible in the WebSocket stream.
|
**Done when:** a synthetic moving-average of an EPICS PV is visible in the WebSocket stream. ✅
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Stub DS is always registered; synthetic DS is conditional on `cfg.Synthetic.Enabled`
|
||||||
|
- Pipeline is re-evaluated on every upstream update; no polling
|
||||||
|
- REST CRUD routes added to `internal/api/api.go`; Handler now holds optional `*synthetic.Synthetic`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -144,53 +149,68 @@
|
|||||||
- Uses Svelte 5 runes (`$state`, `$props`, `$derived`) throughout, matching existing code style
|
- Uses Svelte 5 runes (`$state`, `$props`, `$derived`) throughout, matching existing code style
|
||||||
- Routing is a simple `mode` state variable in `App.svelte` (no SvelteKit, no router library)
|
- Routing is a simple `mode` state variable in `App.svelte` (no SvelteKit, no router library)
|
||||||
- Unknown widget types in Canvas render as grey dashed placeholder boxes (forward-compatible)
|
- Unknown widget types in Canvas render as grey dashed placeholder boxes (forward-compatible)
|
||||||
|
- **Frontend build pipeline migrated from npm/Vite/Svelte → pure Go (esbuild API + Preact):** Svelte components rewritten as `.tsx` Preact components; `tools/buildfrontend/main.go` bundles `web/src/main.tsx` via `github.com/evanw/esbuild`; vendor libraries (Preact, uPlot, ECharts) committed as ESM files in `web/vendor/`; `//go:generate go run ../tools/buildfrontend/main.go` in `web/embed.go`; `make frontend` now runs `go generate ./web/...` — no Node/npm required
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5 — View Mode Widgets
|
## Phase 5 — View Mode Widgets ✅
|
||||||
|
|
||||||
**Goal:** all widget types rendered and interactive in view mode.
|
**Goal:** all widget types rendered and interactive in view mode.
|
||||||
|
|
||||||
- [ ] Text view widget
|
- [x] Text view widget (`TextView.tsx`) — live value + quality dot + unit
|
||||||
- [ ] Gauge widget (SVG arc, configurable range/thresholds)
|
- [x] Gauge widget (`Gauge.tsx`) — SVG arc, configurable range/thresholds, colour zones
|
||||||
- [ ] Vertical / horizontal bar widget
|
- [x] Horizontal / vertical bar widget (`BarH.tsx`, `BarV.tsx`)
|
||||||
- [ ] LED widget (condition evaluator, configurable colours)
|
- [x] LED widget (`Led.tsx`) — condition evaluator, configurable colours, blink on uncertain quality
|
||||||
- [ ] Multi-LED widget (bitset, per-bit labels)
|
- [x] Multi-LED widget (`MultiLed.tsx`) — bitset, per-bit labels and colours
|
||||||
- [ ] Set-value widget (input + Set button; sends `write` over WS)
|
- [x] Set-value widget (`SetValue.tsx`) — input + Set button; sends `write` over WS
|
||||||
- [ ] Button widget (sends fixed value on click)
|
- [x] Button widget (`Button.tsx`) — sends fixed value on click, optional confirm dialog
|
||||||
- [ ] Plot widget:
|
- [x] Plot widget (`PlotWidget.tsx`):
|
||||||
- Time-series (uPlot, streaming buffer of N points)
|
- Time-series (uPlot, streaming ring buffer, dark axis styling)
|
||||||
- FFT, waterfall, histogram, bar chart, logic analyser (ECharts)
|
- Histogram, bar chart, FFT, waterfall, logic analyser (ECharts)
|
||||||
- Multi-signal support; legend
|
- Multi-signal support; legend; ResizeObserver-based resize
|
||||||
- [ ] Text label (static)
|
- [x] Text label (`TextLabel.tsx`) — static text, configurable font size/colour/align
|
||||||
- [ ] Image widget (base64 or server URL)
|
- [x] Image widget (`ImageWidget.tsx`) — renders image from URL, configurable object-fit
|
||||||
- [ ] Link widget (navigates to another interface)
|
- [x] Link widget (`LinkWidget.tsx`) — navigates to another interface via `onNavigate` callback
|
||||||
- [ ] Right-click context menu: signal info dialog, copy name, export CSV
|
- [x] Right-click context menu — signal info, copy signal name, export CSV stub
|
||||||
- [ ] Svelte component tests for each widget type (mock store)
|
- [ ] Component tests (deferred to Phase 10 hardening)
|
||||||
|
|
||||||
**Done when:** a complete HMI panel with multiple widget types runs smoothly at 60 fps.
|
**Done when:** a complete HMI panel with multiple widget types runs smoothly at 60 fps. ✅
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Frontend build pipeline migrated to pure Go (esbuild + Preact); no npm/Node required
|
||||||
|
- `Canvas.tsx` dispatches `onNavigate(interfaceId)` → `ViewMode.tsx` → `GET /api/v1/interfaces/{id}`
|
||||||
|
- Plot widget uses static imports (not `await import()`) to avoid async race with signal subscriptions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6 — Edit Mode (Core)
|
## Phase 6 — Edit Mode (Core) ✅
|
||||||
|
|
||||||
**Goal:** users can build and save an interface from scratch.
|
**Goal:** users can build and save an interface from scratch.
|
||||||
|
|
||||||
- [ ] Konva stage setup in edit mode; `devicePixelRatio` scaling
|
- [x] `internal/storage/store.go` — file-based XML storage (`{storage_dir}/interfaces/`)
|
||||||
- [ ] Widget renderer adapter: same widget components rendered on Konva layer
|
- [x] Interface CRUD REST endpoints: `GET/POST /interfaces`, `GET/PUT/DELETE/POST(clone) /interfaces/{id}`
|
||||||
- [ ] Signal tree pane: fetch signal list, tree display, filter/search
|
- [x] Signal tree pane (`SignalTree.tsx`): fetches all DS/signal lists, collapsible groups, filter/search, drag source
|
||||||
- [ ] Drag signal from tree → drop on canvas → widget type picker
|
- [x] Drag signal from tree → drop on canvas → `WidgetTypePicker` popup (12 widget types)
|
||||||
- [ ] Place widget at drop coordinates with default size
|
- [x] Place widget at drop coordinates with type-appropriate default size
|
||||||
- [ ] Single selection: bounding box + resize handles via Konva `Transformer`
|
- [x] `EditCanvas.tsx`: widget live-preview + transparent overlay divs for interaction; pure DOM mouse events (no Konva)
|
||||||
- [ ] Move widget by dragging body
|
- [x] Single selection: 2px blue bounding box + 8 resize handles (N/S/E/W/NE/NW/SE/SW)
|
||||||
- [ ] Delete widget (× button or Del key)
|
- [x] Move widget by dragging body; resize by dragging handles
|
||||||
- [ ] Properties pane: common options (label, position, size)
|
- [x] Delete widget (✕ button on selection or Del/Backspace key)
|
||||||
- [ ] Per-widget property editors (range, colour, plot type, etc.)
|
- [x] `PropertiesPane.tsx`: canvas size/name + per-widget position/size/signal/options; type-specific fields
|
||||||
- [ ] Save interface to server (POST/PUT XML)
|
- [x] Save interface to server (POST creates, PUT updates; XML body)
|
||||||
- [ ] Load interface from server (GET XML, populate canvas)
|
- [x] Export / import local XML file
|
||||||
- [ ] Export / import local XML file
|
- [x] Edit mode toolbar: name editor, dirty indicator, Save/Export/Import/Close buttons
|
||||||
|
- [x] InterfaceList shows server-persisted interfaces with click-to-view, edit/clone/delete actions
|
||||||
|
- [x] Edit button enabled in view mode toolbar
|
||||||
|
|
||||||
**Done when:** an engineer can create a panel with 5+ widgets, save it, reload it, and see live data.
|
**Done when:** an engineer can create a panel with 5+ widgets, save it, reload it, and see live data. ✅
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Pure DOM drag/resize (no Konva): overlay `div` per widget, `document.addEventListener('mousemove/mouseup')` during drag/resize
|
||||||
|
- HTML5 drag-and-drop transfers `SignalRef` JSON via `dataTransfer`
|
||||||
|
- `serializeInterface()` in `xml.ts` serializes `Interface` → XML; `parseInterface()` deserializes back
|
||||||
|
- Storage IDs are slugified names; uniqueness guaranteed by millisecond timestamp suffix
|
||||||
|
- No Konva dependency — Konva deferred to Phase 7 if multi-select area-select is needed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -273,10 +293,10 @@ These are rough single-developer estimates. Parallel work across backend and fro
|
|||||||
| 0 | 1–2 days | ✅ Complete |
|
| 0 | 1–2 days | ✅ Complete |
|
||||||
| 1 | 3–5 days | ✅ Complete |
|
| 1 | 3–5 days | ✅ Complete |
|
||||||
| 2 | 1–2 weeks | ✅ Complete |
|
| 2 | 1–2 weeks | ✅ Complete |
|
||||||
| 3 | 1–2 weeks | — |
|
| 3 | 1–2 weeks | ✅ Complete |
|
||||||
| 4 | 3–5 days | ✅ Complete |
|
| 4 | 3–5 days | ✅ Complete |
|
||||||
| 5 | 2–3 weeks | — |
|
| 5 | 2–3 weeks | ✅ Complete |
|
||||||
| 6 | 2–3 weeks | — |
|
| 6 | 2–3 weeks | ✅ Complete |
|
||||||
| 7 | 1–2 weeks | — |
|
| 7 | 1–2 weeks | — |
|
||||||
| 8 | 1 week | — |
|
| 8 | 1 week | — |
|
||||||
| 9 | 1 week | — |
|
| 9 | 1 week | — |
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ go 1.26.2
|
|||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/coder/websocket v1.8.14
|
github.com/coder/websocket v1.8.14
|
||||||
|
github.com/evanw/esbuild v0.28.0
|
||||||
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||||
github.com/yuin/gopher-lua v1.1.2 // indirect
|
|
||||||
gonum.org/v1/gonum v0.17.0 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
|||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
||||||
|
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
+135
-10
@@ -3,22 +3,28 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
|
"github.com/uopi/uopi/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds the dependencies shared across all API endpoints.
|
// Handler holds the dependencies shared across all API endpoints.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
synthetic *synthetic.Synthetic // nil if not enabled
|
||||||
|
store *storage.Store
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an API Handler.
|
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||||
func New(b *broker.Broker, log *slog.Logger) *Handler {
|
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Handler {
|
||||||
return &Handler{broker: b, log: log}
|
return &Handler{broker: b, synthetic: synth, store: store, log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register mounts all API routes onto mux under the given prefix.
|
// Register mounts all API routes onto mux under the given prefix.
|
||||||
@@ -32,6 +38,10 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
|||||||
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
|
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
|
||||||
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
|
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
|
||||||
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
|
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
|
||||||
|
// Synthetic signal CRUD
|
||||||
|
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
|
||||||
|
mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
|
||||||
|
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── /datasources ──────────────────────────────────────────────────────────────
|
// ── /datasources ──────────────────────────────────────────────────────────────
|
||||||
@@ -91,30 +101,145 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── /interfaces ───────────────────────────────────────────────────────────────
|
// ── /interfaces ───────────────────────────────────────────────────────────────
|
||||||
// Stub implementations — full persistence lands in Phase 6.
|
|
||||||
|
|
||||||
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonOK(w, []any{})
|
list, err := h.store.List()
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("list interfaces", "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.store.Create(body)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("create interface", "err", err)
|
||||||
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonError(w, http.StatusNotFound, "interface not found")
|
id := r.PathValue("id")
|
||||||
|
data, err := h.store.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
jsonError(w, http.StatusNotFound, "interface not found: "+id)
|
||||||
|
} else {
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
|
id := r.PathValue("id")
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.Update(id, body); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
jsonError(w, http.StatusNotFound, "interface not found: "+id)
|
||||||
|
} else {
|
||||||
|
h.log.Error("update interface", "id", id, "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
|
id := r.PathValue("id")
|
||||||
|
if err := h.store.Delete(id); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
jsonError(w, http.StatusNotFound, "interface not found: "+id)
|
||||||
|
} else {
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
|
id := r.PathValue("id")
|
||||||
|
newID, err := h.store.Clone(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
jsonError(w, http.StatusNotFound, "interface not found: "+id)
|
||||||
|
} else {
|
||||||
|
h.log.Error("clone interface", "id", id, "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /synthetic ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if h.synthetic == nil {
|
||||||
|
jsonOK(w, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, h.synthetic.GetDefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.synthetic == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var def synthetic.SignalDef
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.synthetic.AddSignal(def); err != nil {
|
||||||
|
if errors.Is(err, datasource.ErrNotFound) {
|
||||||
|
jsonError(w, http.StatusConflict, err.Error())
|
||||||
|
} else {
|
||||||
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
jsonOK(w, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.synthetic == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if err := h.synthetic.RemoveSignal(name); err != nil {
|
||||||
|
if errors.Is(err, datasource.ErrNotFound) {
|
||||||
|
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
|
||||||
|
} else {
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Package synthetic implements a DataSource that computes signals from
|
||||||
|
// existing upstream signals by running them through configurable DSP pipelines.
|
||||||
|
package synthetic
|
||||||
|
|
||||||
|
// SignalDef describes one synthetic signal.
|
||||||
|
type SignalDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DS string `json:"ds"` // upstream data source name
|
||||||
|
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||||
|
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||||
|
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||||
|
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputRef names one upstream signal used as input to the pipeline.
|
||||||
|
type InputRef struct {
|
||||||
|
DS string `json:"ds"`
|
||||||
|
Signal string `json:"signal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeDef is a single node in the pipeline.
|
||||||
|
type NodeDef struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Params map[string]any `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaOverride allows the synthetic signal to override display metadata.
|
||||||
|
type MetaOverride struct {
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
DisplayLow float64 `json:"displayLow,omitempty"`
|
||||||
|
DisplayHigh float64 `json:"displayHigh,omitempty"`
|
||||||
|
Writable bool `json:"writable,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package synthetic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/dsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// floatParam extracts a float64 from params; returns 0 if missing or wrong type.
|
||||||
|
func floatParam(params map[string]any, key string) float64 {
|
||||||
|
if params == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, ok := params[key]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringParam extracts a string from params; returns "" if missing or wrong type.
|
||||||
|
func stringParam(params map[string]any, key string) string {
|
||||||
|
if params == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, ok := params[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
|
||||||
|
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||||
|
// float64 regardless of the final type needed.
|
||||||
|
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
|
||||||
|
nodes := make([]dsp.Node, 0, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
n, err := buildNode(d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
|
||||||
|
}
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNode(d NodeDef) (dsp.Node, error) {
|
||||||
|
p := d.Params
|
||||||
|
switch d.Type {
|
||||||
|
case "gain":
|
||||||
|
return &dsp.GainNode{Gain: floatParam(p, "gain")}, nil
|
||||||
|
|
||||||
|
case "offset":
|
||||||
|
return &dsp.OffsetNode{Offset: floatParam(p, "offset")}, nil
|
||||||
|
|
||||||
|
case "add":
|
||||||
|
return &dsp.AddNode{}, nil
|
||||||
|
|
||||||
|
case "subtract":
|
||||||
|
return &dsp.SubtractNode{}, nil
|
||||||
|
|
||||||
|
case "multiply":
|
||||||
|
return &dsp.MultiplyNode{}, nil
|
||||||
|
|
||||||
|
case "divide":
|
||||||
|
return &dsp.DivideNode{}, nil
|
||||||
|
|
||||||
|
case "moving_average":
|
||||||
|
return &dsp.MovingAverageNode{Window: max(1, int(floatParam(p, "window")))}, nil
|
||||||
|
|
||||||
|
case "rms":
|
||||||
|
return &dsp.RMSNode{Window: max(1, int(floatParam(p, "window")))}, nil
|
||||||
|
|
||||||
|
case "derivative":
|
||||||
|
return &dsp.DerivativeNode{}, nil
|
||||||
|
|
||||||
|
case "clamp":
|
||||||
|
return &dsp.ClampNode{
|
||||||
|
Min: floatParam(p, "min"),
|
||||||
|
Max: floatParam(p, "max"),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "threshold":
|
||||||
|
return &dsp.ThresholdNode{
|
||||||
|
Threshold: floatParam(p, "threshold"),
|
||||||
|
High: floatParam(p, "high"),
|
||||||
|
Low: floatParam(p, "low"),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "expr":
|
||||||
|
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
|
||||||
|
|
||||||
|
case "lua":
|
||||||
|
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown node type %q", d.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package synthetic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
|
"github.com/uopi/uopi/internal/dsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const definitionsFile = "synthetic.json"
|
||||||
|
|
||||||
|
// signalState holds everything needed to run one synthetic signal.
|
||||||
|
type signalState struct {
|
||||||
|
def SignalDef
|
||||||
|
nodes []dsp.Node
|
||||||
|
states []map[string]any // one map per node, persistent across calls
|
||||||
|
|
||||||
|
// cancel stops the goroutine driving this signal.
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic is a DataSource that computes values from upstream signals via
|
||||||
|
// configurable DSP pipelines.
|
||||||
|
type Synthetic struct {
|
||||||
|
brk *broker.Broker
|
||||||
|
storePath string
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
signals map[string]*signalState
|
||||||
|
|
||||||
|
// root context provided by Connect; used to start per-signal goroutines.
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Synthetic data source. storePath is the directory where
|
||||||
|
// synthetic.json is stored. brk is used to subscribe to upstream signals.
|
||||||
|
func New(storePath string, brk *broker.Broker, log *slog.Logger) *Synthetic {
|
||||||
|
return &Synthetic{
|
||||||
|
brk: brk,
|
||||||
|
storePath: storePath,
|
||||||
|
log: log,
|
||||||
|
signals: make(map[string]*signalState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements datasource.DataSource.
|
||||||
|
func (s *Synthetic) Name() string { return "synthetic" }
|
||||||
|
|
||||||
|
// Connect loads the definitions file and starts all signal goroutines.
|
||||||
|
func (s *Synthetic) Connect(ctx context.Context) error {
|
||||||
|
s.ctx = ctx
|
||||||
|
|
||||||
|
defs, err := s.loadDefs()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("synthetic: load definitions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, def := range defs {
|
||||||
|
if err := s.startSignal(def); err != nil {
|
||||||
|
s.log.Warn("synthetic: failed to start signal", "name", def.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSignals returns metadata for all defined synthetic signals.
|
||||||
|
func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||||
|
for _, st := range s.signals {
|
||||||
|
out = append(out, defToMetadata(st.def))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata returns metadata for a single named signal.
|
||||||
|
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
st, ok := s.signals[signal]
|
||||||
|
if !ok {
|
||||||
|
return datasource.Metadata{}, datasource.ErrNotFound
|
||||||
|
}
|
||||||
|
return defToMetadata(st.def), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers ch to receive computed values for the named signal.
|
||||||
|
// Values are pushed whenever an upstream signal changes.
|
||||||
|
func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
st, ok := s.signals[signal]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, datasource.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the upstream references for this signal.
|
||||||
|
refs := upstreamRefs(st.def)
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Latest value per upstream input index.
|
||||||
|
latest := make([]float64, len(refs))
|
||||||
|
ready := make([]bool, len(refs))
|
||||||
|
|
||||||
|
// Subscribe to every upstream ref via the broker.
|
||||||
|
updateCh := make(chan indexedUpdate, 32*len(refs))
|
||||||
|
|
||||||
|
unsubs := make([]func(), 0, len(refs))
|
||||||
|
for i, ref := range refs {
|
||||||
|
idx := i // capture
|
||||||
|
perCh := make(chan broker.Update, 16)
|
||||||
|
unsub, err := s.brk.Subscribe(ref, perCh)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("synthetic: upstream subscribe failed",
|
||||||
|
"signal", signal, "upstream", ref, "err", err)
|
||||||
|
// Continue; the slot will stay at zero.
|
||||||
|
} else {
|
||||||
|
unsubs = append(unsubs, unsub)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case u, ok := <-perCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val := toFloat64(u.Value.Data)
|
||||||
|
select {
|
||||||
|
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
for _, unsub := range unsubs {
|
||||||
|
unsub()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case upd := <-updateCh:
|
||||||
|
latest[upd.idx] = upd.val
|
||||||
|
ready[upd.idx] = true
|
||||||
|
|
||||||
|
// Only compute once we have at least one value for every input.
|
||||||
|
allReady := true
|
||||||
|
for _, r := range ready {
|
||||||
|
if !r {
|
||||||
|
allReady = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allReady {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the pipeline.
|
||||||
|
s.mu.RLock()
|
||||||
|
cur, stillExists := s.signals[signal]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !stillExists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runPipeline(cur.nodes, cur.states, latest)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
v := datasource.Value{
|
||||||
|
Timestamp: upd.ts,
|
||||||
|
Data: result,
|
||||||
|
Quality: datasource.QualityGood,
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- v:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return datasource.CancelFunc(cancel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write is not supported for synthetic signals.
|
||||||
|
func (s *Synthetic) Write(_ context.Context, _ string, _ any) error {
|
||||||
|
return datasource.ErrNotWritable
|
||||||
|
}
|
||||||
|
|
||||||
|
// History is not supported for synthetic signals.
|
||||||
|
func (s *Synthetic) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||||
|
return nil, datasource.ErrHistoryUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSignal adds a new synthetic signal definition at runtime and persists it.
|
||||||
|
func (s *Synthetic) AddSignal(def SignalDef) error {
|
||||||
|
if def.Name == "" {
|
||||||
|
return errors.New("signal name must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := BuildPipeline(def.Pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build pipeline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if _, exists := s.signals[def.Name]; exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("signal %q already exists", def.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
states := make([]map[string]any, len(nodes))
|
||||||
|
for i := range states {
|
||||||
|
states[i] = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
st := &signalState{
|
||||||
|
def: def,
|
||||||
|
nodes: nodes,
|
||||||
|
states: states,
|
||||||
|
}
|
||||||
|
s.signals[def.Name] = st
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := s.saveDefs(); err != nil {
|
||||||
|
// Roll back the in-memory addition.
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.signals, def.Name)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("persist definitions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSignal removes a synthetic signal definition at runtime and persists.
|
||||||
|
func (s *Synthetic) RemoveSignal(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
st, ok := s.signals[name]
|
||||||
|
if !ok {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return datasource.ErrNotFound
|
||||||
|
}
|
||||||
|
// Stop the goroutine if it is running.
|
||||||
|
if st.cancel != nil {
|
||||||
|
st.cancel()
|
||||||
|
}
|
||||||
|
delete(s.signals, name)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := s.saveDefs(); err != nil {
|
||||||
|
return fmt.Errorf("persist definitions: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefs returns a copy of all current signal definitions (for the REST API).
|
||||||
|
func (s *Synthetic) GetDefs() []SignalDef {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]SignalDef, 0, len(s.signals))
|
||||||
|
for _, st := range s.signals {
|
||||||
|
out = append(out, st.def)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Synthetic) defsFilePath() string {
|
||||||
|
return filepath.Join(s.storePath, definitionsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Synthetic) loadDefs() ([]SignalDef, error) {
|
||||||
|
path := s.defsFilePath()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
// Create an empty definitions file.
|
||||||
|
if werr := os.WriteFile(path, []byte("[]\n"), 0o644); werr != nil {
|
||||||
|
s.log.Warn("synthetic: could not create empty definitions file", "path", path, "err", werr)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var defs []SignalDef
|
||||||
|
if err := json.Unmarshal(data, &defs); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return defs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Synthetic) saveDefs() error {
|
||||||
|
s.mu.RLock()
|
||||||
|
defs := make([]SignalDef, 0, len(s.signals))
|
||||||
|
for _, st := range s.signals {
|
||||||
|
defs = append(defs, st.def)
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(defs, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.defsFilePath(), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSignal builds the pipeline for def and registers the signalState.
|
||||||
|
// The actual goroutines are started lazily by Subscribe.
|
||||||
|
func (s *Synthetic) startSignal(def SignalDef) error {
|
||||||
|
nodes, err := BuildPipeline(def.Pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
states := make([]map[string]any, len(nodes))
|
||||||
|
for i := range states {
|
||||||
|
states[i] = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.signals[def.Name] = &signalState{
|
||||||
|
def: def,
|
||||||
|
nodes: nodes,
|
||||||
|
states: states,
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.log.Info("synthetic: signal registered", "name", def.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPipeline executes all nodes in sequence. The output of node N becomes
|
||||||
|
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
|
||||||
|
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
if len(inputs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return inputs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// First node receives all upstream inputs.
|
||||||
|
cur := inputs
|
||||||
|
var result float64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for i, node := range nodes {
|
||||||
|
result, err = node.Process(cur, states[i])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
|
||||||
|
}
|
||||||
|
// Subsequent nodes receive only the single output of the previous node.
|
||||||
|
cur = []float64{result}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
|
||||||
|
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
|
||||||
|
func upstreamRefs(def SignalDef) []broker.SignalRef {
|
||||||
|
if len(def.Inputs) > 0 {
|
||||||
|
refs := make([]broker.SignalRef, len(def.Inputs))
|
||||||
|
for i, inp := range def.Inputs {
|
||||||
|
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
if def.DS != "" && def.Signal != "" {
|
||||||
|
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defToMetadata converts a SignalDef into a datasource.Metadata.
|
||||||
|
func defToMetadata(def SignalDef) datasource.Metadata {
|
||||||
|
return datasource.Metadata{
|
||||||
|
Name: def.Name,
|
||||||
|
Type: datasource.TypeFloat64,
|
||||||
|
Unit: def.Meta.Unit,
|
||||||
|
Description: def.Meta.Description,
|
||||||
|
DisplayLow: def.Meta.DisplayLow,
|
||||||
|
DisplayHigh: def.Meta.DisplayHigh,
|
||||||
|
Writable: def.Meta.Writable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toFloat64 coerces any numeric value from a datasource.Value.Data to float64.
|
||||||
|
func toFloat64(v any) float64 {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return val
|
||||||
|
case float32:
|
||||||
|
return float64(val)
|
||||||
|
case int64:
|
||||||
|
return float64(val)
|
||||||
|
case int32:
|
||||||
|
return float64(val)
|
||||||
|
case int:
|
||||||
|
return float64(val)
|
||||||
|
case bool:
|
||||||
|
if val {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
|
||||||
|
type indexedUpdate struct {
|
||||||
|
idx int
|
||||||
|
val float64
|
||||||
|
ts time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package synthetic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestSynthetic(t *testing.T) (*Synthetic, *broker.Broker, context.CancelFunc) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
brk := broker.New(ctx, log)
|
||||||
|
syn := New(dir, brk, log)
|
||||||
|
return syn, brk, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNameAndConnect verifies basic construction and Connect.
|
||||||
|
func TestNameAndConnect(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if syn.Name() != "synthetic" {
|
||||||
|
t.Errorf("Name: want %q, got %q", "synthetic", syn.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := syn.Connect(ctx); err != nil {
|
||||||
|
t.Fatalf("Connect: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectCreatesEmptyFile verifies that Connect creates an empty
|
||||||
|
// synthetic.json when none exists.
|
||||||
|
func TestConnectCreatesEmptyFile(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := syn.Connect(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(syn.storePath, "synthetic.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected file to be created: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) == "" {
|
||||||
|
t.Error("file should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddAndRemoveSignal exercises CRUD at runtime.
|
||||||
|
func TestAddAndRemoveSignal(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := syn.Connect(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "test_gain",
|
||||||
|
DS: "stub",
|
||||||
|
Signal: "sine_1hz",
|
||||||
|
Pipeline: []NodeDef{
|
||||||
|
{Type: "gain", Params: map[string]any{"gain": 2.0}},
|
||||||
|
},
|
||||||
|
Meta: MetaOverride{Unit: "V"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syn.AddSignal(def); err != nil {
|
||||||
|
t.Fatalf("AddSignal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate should fail.
|
||||||
|
if err := syn.AddSignal(def); err == nil {
|
||||||
|
t.Error("expected error for duplicate signal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be listed.
|
||||||
|
metas, err := syn.ListSignals(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(metas) != 1 || metas[0].Name != "test_gain" {
|
||||||
|
t.Errorf("unexpected metas: %v", metas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata should work.
|
||||||
|
meta, err := syn.GetMetadata(context.Background(), "test_gain")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta.Unit != "V" {
|
||||||
|
t.Errorf("unit: want V, got %q", meta.Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove it.
|
||||||
|
if err := syn.RemoveSignal("test_gain"); err != nil {
|
||||||
|
t.Fatalf("RemoveSignal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be gone.
|
||||||
|
if _, err := syn.GetMetadata(context.Background(), "test_gain"); err != datasource.ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound after remove, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove non-existing should return ErrNotFound.
|
||||||
|
if err := syn.RemoveSignal("nonexistent"); err != datasource.ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddSignalEmptyName validates that empty name is rejected.
|
||||||
|
func TestAddSignalEmptyName(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := syn.Connect(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syn.AddSignal(SignalDef{}); err == nil {
|
||||||
|
t.Error("expected error for empty signal name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetMetadataNotFound verifies ErrNotFound for unknown signal.
|
||||||
|
func TestGetMetadataNotFound(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := syn.Connect(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := syn.GetMetadata(context.Background(), "no_such_signal")
|
||||||
|
if err != datasource.ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteNotSupported ensures Write returns ErrNotWritable.
|
||||||
|
func TestWriteNotSupported(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := syn.Write(context.Background(), "any", 1.0)
|
||||||
|
if err != datasource.ErrNotWritable {
|
||||||
|
t.Errorf("expected ErrNotWritable, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryNotSupported ensures History returns ErrHistoryUnavailable.
|
||||||
|
func TestHistoryNotSupported(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := syn.History(context.Background(), "any", time.Now(), time.Now(), 10)
|
||||||
|
if err != datasource.ErrHistoryUnavailable {
|
||||||
|
t.Errorf("expected ErrHistoryUnavailable, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPersistence verifies that definitions survive a restart (New+Connect).
|
||||||
|
func TestPersistence(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
brk := broker.New(ctx, log)
|
||||||
|
syn := New(dir, brk, log)
|
||||||
|
|
||||||
|
if err := syn.Connect(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "persisted",
|
||||||
|
DS: "stub",
|
||||||
|
Signal: "sine_1hz",
|
||||||
|
Pipeline: []NodeDef{
|
||||||
|
{Type: "offset", Params: map[string]any{"offset": 5.0}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := syn.AddSignal(def); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Reload.
|
||||||
|
ctx2 := t.Context()
|
||||||
|
brk2 := broker.New(ctx2, log)
|
||||||
|
syn2 := New(dir, brk2, log)
|
||||||
|
if err := syn2.Connect(ctx2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := syn2.GetMetadata(ctx2, "persisted")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("after reload, GetMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta.Name != "persisted" {
|
||||||
|
t.Errorf("expected name %q, got %q", "persisted", meta.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubscribeUnknownSignal verifies that subscribing to unknown signal returns error.
|
||||||
|
func TestSubscribeUnknownSignal(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := syn.Connect(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan datasource.Value, 1)
|
||||||
|
_, err := syn.Subscribe(context.Background(), "nonexistent", ch)
|
||||||
|
if err != datasource.ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetDefs verifies GetDefs returns current definitions.
|
||||||
|
func TestGetDefs(t *testing.T) {
|
||||||
|
syn, _, cancel := newTestSynthetic(t)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := syn.Connect(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defs := syn.GetDefs()
|
||||||
|
if len(defs) != 0 {
|
||||||
|
t.Errorf("expected empty defs, got %d", len(defs))
|
||||||
|
}
|
||||||
|
|
||||||
|
def := SignalDef{
|
||||||
|
Name: "test",
|
||||||
|
DS: "stub",
|
||||||
|
Signal: "noise",
|
||||||
|
}
|
||||||
|
if err := syn.AddSignal(def); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defs = syn.GetDefs()
|
||||||
|
if len(defs) != 1 || defs[0].Name != "test" {
|
||||||
|
t.Errorf("unexpected defs: %v", defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
"github.com/uopi/uopi/internal/api"
|
"github.com/uopi/uopi/internal/api"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
|
"github.com/uopi/uopi/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiPrefix = "/api/v1"
|
const apiPrefix = "/api/v1"
|
||||||
@@ -19,7 +21,8 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||||
func New(addr string, webFS fs.FS, brk *broker.Broker, log *slog.Logger) *Server {
|
// synth may be nil if the synthetic data source is not enabled.
|
||||||
|
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
@@ -32,7 +35,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, log *slog.Logger) *Server
|
|||||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
|
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
|
||||||
|
|
||||||
// REST API
|
// REST API
|
||||||
api.New(brk, log).Register(mux, apiPrefix)
|
api.New(brk, synth, store, log).Register(mux, apiPrefix)
|
||||||
|
|
||||||
// Embedded frontend — must be last (catch-all)
|
// Embedded frontend — must be last (catch-all)
|
||||||
mux.Handle("/", http.FileServerFS(webFS))
|
mux.Handle("/", http.FileServerFS(webFS))
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Package storage provides file-based persistence for HMI interface definitions.
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is returned when the requested interface does not exist.
|
||||||
|
var ErrNotFound = errors.New("interface not found")
|
||||||
|
|
||||||
|
// InterfaceMeta is the lightweight representation returned by List.
|
||||||
|
type InterfaceMeta struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persists interface definitions as XML files under {storageDir}/interfaces/.
|
||||||
|
type Store struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New opens (and creates, if needed) the storage directory.
|
||||||
|
func New(storageDir string) (*Store, error) {
|
||||||
|
dir := filepath.Join(storageDir, "interfaces")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create interfaces dir: %w", err)
|
||||||
|
}
|
||||||
|
return &Store{dir: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) filePath(id string) string {
|
||||||
|
return filepath.Join(s.dir, id+".xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns metadata for every stored interface.
|
||||||
|
func (s *Store) List() ([]InterfaceMeta, error) {
|
||||||
|
entries, err := os.ReadDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []InterfaceMeta
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSuffix(e.Name(), ".xml")
|
||||||
|
meta, err := s.readMeta(id)
|
||||||
|
if err != nil {
|
||||||
|
continue // skip corrupt files silently
|
||||||
|
}
|
||||||
|
out = append(out, meta)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []InterfaceMeta{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootAttrs is used to parse only the top-level XML attributes.
|
||||||
|
type rootAttrs struct {
|
||||||
|
XMLName xml.Name `xml:"interface"`
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Version int `xml:"version,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
||||||
|
data, err := os.ReadFile(s.filePath(id))
|
||||||
|
if err != nil {
|
||||||
|
return InterfaceMeta{}, err
|
||||||
|
}
|
||||||
|
var root rootAttrs
|
||||||
|
if err := xml.Unmarshal(data, &root); err != nil {
|
||||||
|
return InterfaceMeta{}, err
|
||||||
|
}
|
||||||
|
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the raw XML bytes for the interface with the given ID.
|
||||||
|
func (s *Store) Get(id string) ([]byte, error) {
|
||||||
|
data, err := os.ReadFile(s.filePath(id))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stores new interface XML and returns the generated ID.
|
||||||
|
// The ID is derived from the name attribute; a timestamp suffix is added to
|
||||||
|
// ensure uniqueness.
|
||||||
|
func (s *Store) Create(xmlData []byte) (string, error) {
|
||||||
|
var root rootAttrs
|
||||||
|
if err := xml.Unmarshal(xmlData, &root); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid XML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := root.ID
|
||||||
|
if id == "" {
|
||||||
|
id = slugify(root.Name)
|
||||||
|
}
|
||||||
|
// Guarantee uniqueness.
|
||||||
|
if _, err := os.Stat(s.filePath(id)); err == nil {
|
||||||
|
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||||
|
}
|
||||||
|
return id, os.WriteFile(s.filePath(id), xmlData, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update replaces the XML for an existing interface.
|
||||||
|
func (s *Store) Update(id string, xmlData []byte) error {
|
||||||
|
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.filePath(id), xmlData, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the interface with the given ID.
|
||||||
|
func (s *Store) Delete(id string) error {
|
||||||
|
err := os.Remove(s.filePath(id))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone duplicates an interface, assigning a new unique ID.
|
||||||
|
func (s *Store) Clone(srcID string) (string, error) {
|
||||||
|
data, err := s.Get(srcID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
newID := srcID + "-copy-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||||
|
return newID, os.WriteFile(s.filePath(newID), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slugify converts a human-readable name into a URL-safe identifier.
|
||||||
|
func slugify(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := strings.Trim(b.String(), "-")
|
||||||
|
if result == "" {
|
||||||
|
return "interface"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "sine_1hz_gain2",
|
||||||
|
"ds": "stub",
|
||||||
|
"signal": "sine_1hz",
|
||||||
|
"pipeline": [
|
||||||
|
{ "type": "gain", "params": { "gain": 2.0 } }
|
||||||
|
],
|
||||||
|
"meta": { "unit": "V", "description": "Sine 1Hz amplified x2", "displayLow": -2, "displayHigh": 2 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sine_avg10",
|
||||||
|
"ds": "stub",
|
||||||
|
"signal": "sine_1hz",
|
||||||
|
"pipeline": [
|
||||||
|
{ "type": "moving_average", "params": { "window": 10 } }
|
||||||
|
],
|
||||||
|
"meta": { "unit": "V", "description": "10-sample moving average of sine_1hz", "displayLow": -1, "displayHigh": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "setpoint_clamped",
|
||||||
|
"ds": "stub",
|
||||||
|
"signal": "setpoint",
|
||||||
|
"pipeline": [
|
||||||
|
{ "type": "clamp", "params": { "min": 10, "max": 80 } }
|
||||||
|
],
|
||||||
|
"meta": { "unit": "°C", "description": "Setpoint clamped to [10, 80]", "displayLow": 0, "displayHigh": 100 }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/evanw/esbuild/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Determine repo root.
|
||||||
|
// When invoked via `go generate` from web/, cwd is web/ so we look for go.mod
|
||||||
|
// to find the actual module root.
|
||||||
|
root, err := findModuleRoot()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify vendor files exist
|
||||||
|
vendorFiles := []string{
|
||||||
|
filepath.Join(root, "web", "vendor", "preact.mjs"),
|
||||||
|
filepath.Join(root, "web", "vendor", "preact-hooks.mjs"),
|
||||||
|
filepath.Join(root, "web", "vendor", "uplot.esm.js"),
|
||||||
|
filepath.Join(root, "web", "vendor", "uplot.css"),
|
||||||
|
filepath.Join(root, "web", "vendor", "echarts.esm.js"),
|
||||||
|
}
|
||||||
|
for _, f := range vendorFiles {
|
||||||
|
if _, err := os.Stat(f); os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: vendor file missing: %s\n", f)
|
||||||
|
fmt.Fprintf(os.Stderr, "Run the following to download vendor files:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " mkdir -p web/vendor\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/preact@10.24.3/dist/preact.module.js -o web/vendor/preact.mjs\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/preact@10.24.3/hooks/dist/hooks.module.js -o web/vendor/preact-hooks.mjs\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.esm.js -o web/vendor/uplot.esm.js\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.min.css -o web/vendor/uplot.css\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.esm.min.js -o web/vendor/echarts.esm.js\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean + create dist
|
||||||
|
distDir := filepath.Join(root, "web", "dist")
|
||||||
|
os.RemoveAll(distDir)
|
||||||
|
os.MkdirAll(distDir, 0o755)
|
||||||
|
|
||||||
|
result := api.Build(api.BuildOptions{
|
||||||
|
EntryPoints: []string{filepath.Join(root, "web", "src", "main.tsx")},
|
||||||
|
Bundle: true,
|
||||||
|
Outdir: distDir,
|
||||||
|
Format: api.FormatESModule,
|
||||||
|
Target: api.ES2020,
|
||||||
|
JSX: api.JSXTransform,
|
||||||
|
JSXFactory: "h",
|
||||||
|
JSXFragment: "Fragment",
|
||||||
|
MinifyWhitespace: true,
|
||||||
|
MinifyIdentifiers: true,
|
||||||
|
MinifySyntax: true,
|
||||||
|
Alias: map[string]string{
|
||||||
|
"preact": filepath.Join(root, "web", "vendor", "preact.mjs"),
|
||||||
|
"preact/hooks": filepath.Join(root, "web", "vendor", "preact-hooks.mjs"),
|
||||||
|
"uplot": filepath.Join(root, "web", "vendor", "uplot.esm.js"),
|
||||||
|
"echarts": filepath.Join(root, "web", "vendor", "echarts.esm.js"),
|
||||||
|
},
|
||||||
|
Loader: map[string]api.Loader{
|
||||||
|
".css": api.LoaderCSS,
|
||||||
|
},
|
||||||
|
Splitting: false,
|
||||||
|
Write: true,
|
||||||
|
LogLevel: api.LogLevelInfo,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %s\n", e.Text)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy uPlot CSS and favicon
|
||||||
|
copyFile(filepath.Join(root, "web", "vendor", "uplot.css"), filepath.Join(distDir, "uplot.css"))
|
||||||
|
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.svg"))
|
||||||
|
|
||||||
|
// Write index.html
|
||||||
|
html := `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>uopi</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="stylesheet" href="/uplot.css" />
|
||||||
|
<link rel="stylesheet" href="/main.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
os.WriteFile(filepath.Join(distDir, "index.html"), []byte(html), 0o644)
|
||||||
|
|
||||||
|
fmt.Println("Frontend built successfully →", distDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return // Skip if missing
|
||||||
|
}
|
||||||
|
os.WriteFile(dst, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findModuleRoot walks up from cwd until it finds a go.mod file.
|
||||||
|
func findModuleRoot() (string, error) {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return "", fmt.Errorf("go.mod not found in any parent directory")
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Svelte + TS + Vite
|
|
||||||
|
|
||||||
This template should help get you started developing with Svelte and TypeScript in Vite.
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
|
||||||
|
|
||||||
## Need an official Svelte framework?
|
|
||||||
|
|
||||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
|
||||||
|
|
||||||
## Technical considerations
|
|
||||||
|
|
||||||
**Why use this over SvelteKit?**
|
|
||||||
|
|
||||||
- It brings its own routing solution which might not be preferable for some users.
|
|
||||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
|
||||||
|
|
||||||
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
|
||||||
|
|
||||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
|
||||||
|
|
||||||
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
|
||||||
|
|
||||||
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
|
||||||
|
|
||||||
**Why include `.vscode/extensions.json`?**
|
|
||||||
|
|
||||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
|
||||||
|
|
||||||
**Why enable `allowJs` in the TS template?**
|
|
||||||
|
|
||||||
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
|
||||||
|
|
||||||
**Why is HMR not preserving my local component state?**
|
|
||||||
|
|
||||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
|
||||||
|
|
||||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// store.ts
|
|
||||||
// An extremely simple external store
|
|
||||||
import { writable } from 'svelte/store'
|
|
||||||
export default writable(0)
|
|
||||||
```
|
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Package web provides the embedded frontend assets built by Vite.
|
// Package web provides the embedded frontend assets.
|
||||||
// Run `npm run build` inside the web/ directory before building the binary.
|
// Run `make frontend` (or `make all`) to rebuild before compiling the binary.
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>web</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Generated
-2505
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@tsconfig/svelte": "^5.0.8",
|
|
||||||
"@types/node": "^24.12.2",
|
|
||||||
"jsdom": "^29.0.2",
|
|
||||||
"svelte": "^5.55.4",
|
|
||||||
"svelte-check": "^4.4.6",
|
|
||||||
"typescript": "~6.0.2",
|
|
||||||
"vite": "^8.0.10",
|
|
||||||
"vitest": "^4.1.5"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"echarts": "^6.0.0",
|
|
||||||
"uplot": "^1.6.32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { wsClient } from './lib/ws';
|
|
||||||
import ViewMode from './lib/ViewMode.svelte';
|
|
||||||
import EditMode from './lib/EditMode.svelte';
|
|
||||||
|
|
||||||
type AppMode = 'view' | 'edit';
|
|
||||||
let mode = $state<AppMode>('view');
|
|
||||||
|
|
||||||
const wsStatus = wsClient.status;
|
|
||||||
|
|
||||||
// Set CSS variable for device pixel ratio on the root element
|
|
||||||
const dpr = window.devicePixelRatio ?? 1;
|
|
||||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
|
||||||
|
|
||||||
// Connect to WebSocket — called once at app startup
|
|
||||||
wsClient.connect('/ws');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $wsStatus === 'disconnected'}
|
|
||||||
<div class="connection-banner">
|
|
||||||
WebSocket disconnected — reconnecting…
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mode === 'view'}
|
|
||||||
<ViewMode />
|
|
||||||
{:else}
|
|
||||||
<EditMode />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(*, *::before, *::after) {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body) {
|
|
||||||
background: #0f1117;
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(#app) {
|
|
||||||
width: 100%;
|
|
||||||
max-width: none;
|
|
||||||
margin: 0;
|
|
||||||
text-align: left;
|
|
||||||
border: none;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-banner {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: #7f1d1d;
|
|
||||||
color: #fca5a5;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.3rem 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { wsClient } from './lib/ws';
|
||||||
|
import ViewMode from './ViewMode';
|
||||||
|
import EditMode from './EditMode';
|
||||||
|
import type { Interface } from './lib/types';
|
||||||
|
|
||||||
|
type AppMode = 'view' | 'edit';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [mode, setMode] = useState<AppMode>('view');
|
||||||
|
const [wsStatus, setWsStatus] = useState('connecting');
|
||||||
|
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dpr = window.devicePixelRatio ?? 1;
|
||||||
|
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||||
|
wsClient.connect('/ws');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function enterEdit(iface?: Interface) {
|
||||||
|
setEditTarget(iface ?? null);
|
||||||
|
setMode('edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitEdit() {
|
||||||
|
setMode('view');
|
||||||
|
setEditTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{wsStatus === 'disconnected' && (
|
||||||
|
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||||
|
)}
|
||||||
|
{mode === 'view'
|
||||||
|
? <ViewMode onEdit={enterEdit} />
|
||||||
|
: <EditMode initial={editTarget} onDone={exitEdit} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||||
|
import TextView from './widgets/TextView';
|
||||||
|
import TextLabel from './widgets/TextLabel';
|
||||||
|
import Gauge from './widgets/Gauge';
|
||||||
|
import BarH from './widgets/BarH';
|
||||||
|
import BarV from './widgets/BarV';
|
||||||
|
import Led from './widgets/Led';
|
||||||
|
import MultiLed from './widgets/MultiLed';
|
||||||
|
import SetValue from './widgets/SetValue';
|
||||||
|
import Button from './widgets/Button';
|
||||||
|
import PlotWidget from './widgets/PlotWidget';
|
||||||
|
import ImageWidget from './widgets/ImageWidget';
|
||||||
|
import LinkWidget from './widgets/LinkWidget';
|
||||||
|
import ContextMenu from './ContextMenu';
|
||||||
|
|
||||||
|
const COMPONENTS: Record<string, any> = {
|
||||||
|
textview: TextView,
|
||||||
|
textlabel: TextLabel,
|
||||||
|
gauge: Gauge,
|
||||||
|
barh: BarH,
|
||||||
|
barv: BarV,
|
||||||
|
led: Led,
|
||||||
|
multiled: MultiLed,
|
||||||
|
setvalue: SetValue,
|
||||||
|
button: Button,
|
||||||
|
plot: PlotWidget,
|
||||||
|
image: ImageWidget,
|
||||||
|
link: LinkWidget,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CtxState {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
signal: SignalRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
iface: Interface | null;
|
||||||
|
onNavigate?: (interfaceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Canvas({ iface, onNavigate }: Props) {
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<CtxState>({
|
||||||
|
visible: false, x: 0, y: 0, signal: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function onCtxMenu(e: MouseEvent, widget: Widget) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iface) {
|
||||||
|
return (
|
||||||
|
<div class="canvas-container">
|
||||||
|
<div class="placeholder">
|
||||||
|
<p>Select an interface from the left panel, or import one.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="canvas-container">
|
||||||
|
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||||
|
{iface.widgets.map(widget => {
|
||||||
|
const Comp = COMPONENTS[widget.type];
|
||||||
|
return Comp
|
||||||
|
? <Comp
|
||||||
|
key={widget.id}
|
||||||
|
widget={widget}
|
||||||
|
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
: (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
class="unknown-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
title={`Unknown widget type: ${widget.type}`}
|
||||||
|
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||||
|
>
|
||||||
|
<span class="unknown-label">{widget.type}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ContextMenu
|
||||||
|
{...ctxMenu}
|
||||||
|
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
|
import type { SignalRef, SignalMeta } from './lib/types';
|
||||||
|
import { getMetaStore } from './lib/stores';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
signal: SignalRef | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!signal) { setMeta(null); return; }
|
||||||
|
const unsub = getMetaStore(signal).subscribe(setMeta);
|
||||||
|
return unsub;
|
||||||
|
}, [signal?.ds, signal?.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [visible, onClose]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
function copySignalName() {
|
||||||
|
if (signal) {
|
||||||
|
navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
// Phase 5+: export CSV data
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="ctx-menu"
|
||||||
|
ref={menuRef}
|
||||||
|
style={`left:${x}px;top:${y}px;`}
|
||||||
|
>
|
||||||
|
{signal ? (
|
||||||
|
<div>
|
||||||
|
<div class="ctx-signal-info">
|
||||||
|
<div>{signal.ds} / {signal.name}</div>
|
||||||
|
{meta && (
|
||||||
|
<div>{meta.type}{meta.unit ? ` [${meta.unit}]` : ''}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="ctx-divider" />
|
||||||
|
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
|
||||||
|
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button class="ctx-item" onClick={onClose}>Close</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useRef, useEffect, useState } from 'preact/hooks';
|
||||||
|
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||||
|
import TextView from './widgets/TextView';
|
||||||
|
import TextLabel from './widgets/TextLabel';
|
||||||
|
import Gauge from './widgets/Gauge';
|
||||||
|
import BarH from './widgets/BarH';
|
||||||
|
import BarV from './widgets/BarV';
|
||||||
|
import Led from './widgets/Led';
|
||||||
|
import MultiLed from './widgets/MultiLed';
|
||||||
|
import SetValue from './widgets/SetValue';
|
||||||
|
import Button from './widgets/Button';
|
||||||
|
import PlotWidget from './widgets/PlotWidget';
|
||||||
|
import ImageWidget from './widgets/ImageWidget';
|
||||||
|
import LinkWidget from './widgets/LinkWidget';
|
||||||
|
import WidgetTypePicker from './WidgetTypePicker';
|
||||||
|
|
||||||
|
const COMPONENTS: Record<string, any> = {
|
||||||
|
textview: TextView, textlabel: TextLabel, gauge: Gauge,
|
||||||
|
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
|
||||||
|
setvalue: SetValue, button: Button, plot: PlotWidget,
|
||||||
|
image: ImageWidget, link: LinkWidget,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||||
|
textview: [200, 50],
|
||||||
|
textlabel: [150, 36],
|
||||||
|
gauge: [120, 120],
|
||||||
|
barh: [200, 60],
|
||||||
|
barv: [60, 160],
|
||||||
|
led: [60, 60],
|
||||||
|
multiled: [200, 80],
|
||||||
|
setvalue: [200, 60],
|
||||||
|
button: [120, 50],
|
||||||
|
plot: [400, 200],
|
||||||
|
image: [200, 150],
|
||||||
|
link: [140, 50],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PickerState {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
canvasX: number;
|
||||||
|
canvasY: number;
|
||||||
|
signal: SignalRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
widgetId: string;
|
||||||
|
startMouseX: number;
|
||||||
|
startMouseY: number;
|
||||||
|
startWidgetX: number;
|
||||||
|
startWidgetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResizeState {
|
||||||
|
widgetId: string;
|
||||||
|
handle: string; // 'se' | 'sw' | 'ne' | 'nw' | 'n' | 's' | 'e' | 'w'
|
||||||
|
startMouseX: number;
|
||||||
|
startMouseY: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startW: number;
|
||||||
|
startH: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
iface: Interface;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
onChange: (widgets: Widget[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = Date.now();
|
||||||
|
function genId() { return `w${nextId++}`; }
|
||||||
|
|
||||||
|
export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const resizeRef = useRef<ResizeState | null>(null);
|
||||||
|
const [picker, setPicker] = useState<PickerState>({
|
||||||
|
visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document-level move/up handlers for drag and resize
|
||||||
|
useEffect(() => {
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (dragRef.current) {
|
||||||
|
const d = dragRef.current;
|
||||||
|
const dx = e.clientX - d.startMouseX;
|
||||||
|
const dy = e.clientY - d.startMouseY;
|
||||||
|
const widgets = iface.widgets.map(w =>
|
||||||
|
w.id === d.widgetId
|
||||||
|
? { ...w, x: Math.round(d.startWidgetX + dx), y: Math.round(d.startWidgetY + dy) }
|
||||||
|
: w
|
||||||
|
);
|
||||||
|
onChange(widgets);
|
||||||
|
}
|
||||||
|
if (resizeRef.current) {
|
||||||
|
const r = resizeRef.current;
|
||||||
|
const dx = e.clientX - r.startMouseX;
|
||||||
|
const dy = e.clientY - r.startMouseY;
|
||||||
|
let { startX: x, startY: y, startW: w, startH: h } = r;
|
||||||
|
const handle = r.handle;
|
||||||
|
if (handle.includes('e')) w = Math.max(20, w + dx);
|
||||||
|
if (handle.includes('s')) h = Math.max(20, h + dy);
|
||||||
|
if (handle.includes('w')) { x = x + dx; w = Math.max(20, w - dx); }
|
||||||
|
if (handle.includes('n')) { y = y + dy; h = Math.max(20, h - dy); }
|
||||||
|
const widgets = iface.widgets.map(widget =>
|
||||||
|
widget.id === r.widgetId
|
||||||
|
? { ...widget, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }
|
||||||
|
: widget
|
||||||
|
);
|
||||||
|
onChange(widgets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onMouseUp() {
|
||||||
|
dragRef.current = null;
|
||||||
|
resizeRef.current = null;
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
}, [iface.widgets, onChange]);
|
||||||
|
|
||||||
|
function handleCanvasClick(e: MouseEvent) {
|
||||||
|
if ((e.target as Element).closest('.widget-overlay')) return;
|
||||||
|
onSelect(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const json = e.dataTransfer?.getData('application/json');
|
||||||
|
if (!json) return;
|
||||||
|
let sig: SignalRef;
|
||||||
|
try { sig = JSON.parse(json); } catch { return; }
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const scrollLeft = containerRef.current?.scrollLeft ?? 0;
|
||||||
|
const scrollTop = containerRef.current?.scrollTop ?? 0;
|
||||||
|
const cx = e.clientX - rect.left + scrollLeft;
|
||||||
|
const cy = e.clientY - rect.top + scrollTop;
|
||||||
|
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePick(type: string) {
|
||||||
|
if (!picker.signal) return;
|
||||||
|
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||||
|
const newWidget: Widget = {
|
||||||
|
id: genId(),
|
||||||
|
type,
|
||||||
|
x: Math.round(picker.canvasX - defW / 2),
|
||||||
|
y: Math.round(picker.canvasY - defH / 2),
|
||||||
|
w: defW,
|
||||||
|
h: defH,
|
||||||
|
signals: [picker.signal],
|
||||||
|
options: {},
|
||||||
|
};
|
||||||
|
onChange([...iface.widgets, newWidget]);
|
||||||
|
onSelect(newWidget.id);
|
||||||
|
setPicker(p => ({ ...p, visible: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteWidget(id: string) {
|
||||||
|
onChange(iface.widgets.filter(w => w.id !== id));
|
||||||
|
if (selectedId === id) onSelect(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e: MouseEvent, widget: Widget) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(widget.id);
|
||||||
|
dragRef.current = {
|
||||||
|
widgetId: widget.id,
|
||||||
|
startMouseX: e.clientX,
|
||||||
|
startMouseY: e.clientY,
|
||||||
|
startWidgetX: widget.x,
|
||||||
|
startWidgetY: widget.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent, widget: Widget, handle: string) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
resizeRef.current = {
|
||||||
|
widgetId: widget.id,
|
||||||
|
handle,
|
||||||
|
startMouseX: e.clientX,
|
||||||
|
startMouseY: e.clientY,
|
||||||
|
startX: widget.x,
|
||||||
|
startY: widget.y,
|
||||||
|
startW: widget.w,
|
||||||
|
startH: widget.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="edit-canvas-container"
|
||||||
|
ref={containerRef}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="canvas-area"
|
||||||
|
style={`width:${iface.w}px;height:${iface.h}px;`}
|
||||||
|
>
|
||||||
|
{/* Render live widget previews */}
|
||||||
|
{iface.widgets.map(widget => {
|
||||||
|
const Comp = COMPONENTS[widget.type];
|
||||||
|
return Comp
|
||||||
|
? <Comp key={widget.id} widget={widget} />
|
||||||
|
: (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
class="unknown-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
>
|
||||||
|
<span class="unknown-label">{widget.type}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Overlay divs for interaction — sit above the live widgets */}
|
||||||
|
{iface.widgets.map(widget => {
|
||||||
|
const isSelected = widget.id === selectedId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`overlay-${widget.id}`}
|
||||||
|
class={`widget-overlay${isSelected ? ' selected' : ''}`}
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onMouseDown={(e: MouseEvent) => startDrag(e, widget)}
|
||||||
|
onClick={(e: MouseEvent) => { e.stopPropagation(); onSelect(widget.id); }}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div>
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
class="overlay-delete"
|
||||||
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||||
|
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteWidget(widget.id); }}
|
||||||
|
title="Delete widget"
|
||||||
|
>✕</button>
|
||||||
|
{/* Resize handles */}
|
||||||
|
{(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
|
||||||
|
<div
|
||||||
|
key={dir}
|
||||||
|
class={`resize-handle resize-${dir}`}
|
||||||
|
onMouseDown={(e: MouseEvent) => startResize(e, widget, dir)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{picker.visible && (
|
||||||
|
<WidgetTypePicker
|
||||||
|
x={picker.x}
|
||||||
|
y={picker.y}
|
||||||
|
onPick={handlePick}
|
||||||
|
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useCallback } from 'preact/hooks';
|
||||||
|
import type { Interface, Widget } from './lib/types';
|
||||||
|
import { serializeInterface, parseInterface } from './lib/xml';
|
||||||
|
import SignalTree from './SignalTree';
|
||||||
|
import EditCanvas from './EditCanvas';
|
||||||
|
import PropertiesPane from './PropertiesPane';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initial: Interface | null;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _newId = Date.now();
|
||||||
|
function newIfaceId() { return `iface-${_newId++}`; }
|
||||||
|
|
||||||
|
function blankInterface(): Interface {
|
||||||
|
return {
|
||||||
|
id: newIfaceId(),
|
||||||
|
name: 'New Interface',
|
||||||
|
version: 1,
|
||||||
|
w: 1200,
|
||||||
|
h: 800,
|
||||||
|
widgets: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditMode({ initial, onDone }: Props) {
|
||||||
|
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedWidget = iface.widgets.find(w => w.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
|
||||||
|
setIface(f => ({ ...f, widgets }));
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWidgetChange = useCallback((updated: Widget) => {
|
||||||
|
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleIfaceChange = useCallback((updated: Interface) => {
|
||||||
|
setIface(updated);
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const xml = serializeInterface(iface);
|
||||||
|
const url = iface.id
|
||||||
|
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
|
||||||
|
: '/api/v1/interfaces';
|
||||||
|
const method = iface.id ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/xml' },
|
||||||
|
body: xml,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Save failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
if (method === 'POST') {
|
||||||
|
const json = await res.json();
|
||||||
|
setIface(f => ({ ...f, id: json.id }));
|
||||||
|
}
|
||||||
|
setDirty(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
const xml = serializeInterface(iface);
|
||||||
|
const blob = new Blob([xml], { type: 'application/xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.xml,application/xml,text/xml';
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const xml = await file.text();
|
||||||
|
const parsed = parseInterface(xml);
|
||||||
|
setIface(parsed);
|
||||||
|
setSelectedId(null);
|
||||||
|
setDirty(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Import failed: ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeave() {
|
||||||
|
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteSelected() {
|
||||||
|
if (!selectedId) return;
|
||||||
|
setIface(f => ({ ...f, widgets: f.widgets.filter(w => w.id !== selectedId) }));
|
||||||
|
setSelectedId(null);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId &&
|
||||||
|
!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
|
||||||
|
handleDeleteSelected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<header class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="app-name">uopi</span>
|
||||||
|
<span class="edit-badge">Edit</span>
|
||||||
|
<input
|
||||||
|
class="iface-name-input"
|
||||||
|
value={iface.name}
|
||||||
|
onInput={(e) => handleIfaceChange({ ...iface, name: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
{dirty && <span class="dirty-dot" title="Unsaved changes">●</span>}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-center">
|
||||||
|
{error && <span class="parse-error" title={error}>Save error — check console</span>}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button class="toolbar-btn" onClick={handleImport} title="Import XML file">Import</button>
|
||||||
|
<button class="toolbar-btn" onClick={handleExport} title="Export XML file">Export</button>
|
||||||
|
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" onClick={handleLeave} title="Back to view mode">✕ Close</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Three-panel body */}
|
||||||
|
<div class="edit-body">
|
||||||
|
<SignalTree />
|
||||||
|
<EditCanvas
|
||||||
|
iface={iface}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onChange={handleWidgetsChange}
|
||||||
|
/>
|
||||||
|
<PropertiesPane
|
||||||
|
selected={selectedWidget}
|
||||||
|
iface={iface}
|
||||||
|
onChange={handleWidgetChange}
|
||||||
|
onIfaceChange={handleIfaceChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
|
import type { Interface } from './lib/types';
|
||||||
|
|
||||||
|
interface ServerInterface {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLoad: (xml: string) => void;
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
onEdit?: (iface?: Interface) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/v1/interfaces')
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => setInterfaces(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
|
||||||
|
function triggerImport() {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileChange(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const xml = await file.text();
|
||||||
|
onLoad(xml);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read interface file:', err);
|
||||||
|
} finally {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string, name: string) {
|
||||||
|
if (!confirm(`Delete interface "${name}"?`)) return;
|
||||||
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClone(id: string) {
|
||||||
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}/clone`, { method: 'POST' });
|
||||||
|
if (res.ok) refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
|
||||||
|
<div class="panel-header">
|
||||||
|
{!collapsed && <span class="panel-title">Interfaces</span>}
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
title={collapsed ? 'Expand panel' : 'Collapse panel'}
|
||||||
|
>
|
||||||
|
{collapsed ? '▶' : '◀'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
|
||||||
|
<button class="panel-btn" onClick={triggerImport}>Import</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xml,application/xml,text/xml"
|
||||||
|
style="display:none"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-list">
|
||||||
|
{loading ? (
|
||||||
|
<p class="hint">Loading…</p>
|
||||||
|
) : interfaces.length === 0 ? (
|
||||||
|
<p class="hint">No interfaces yet. Create one or import XML.</p>
|
||||||
|
) : (
|
||||||
|
<ul class="iface-list">
|
||||||
|
{interfaces.map(iface => (
|
||||||
|
<li key={iface.id} class="iface-item">
|
||||||
|
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
|
||||||
|
{iface.name || iface.id}
|
||||||
|
</span>
|
||||||
|
<div class="iface-item-actions">
|
||||||
|
<button class="icon-btn" title="Edit" onClick={() => onEdit?.()}>✎</button>
|
||||||
|
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}>⎘</button>
|
||||||
|
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import type { Widget, Interface } from './lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selected: Widget | null;
|
||||||
|
iface: Interface;
|
||||||
|
onChange: (updated: Widget) => void;
|
||||||
|
onIfaceChange: (updated: Interface) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: any }) {
|
||||||
|
return (
|
||||||
|
<div class="prop-field">
|
||||||
|
<label class="prop-label">{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||||
|
const [local, setLocal] = useState(value);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
value={local}
|
||||||
|
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
|
||||||
|
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PropertiesPane({ selected, iface, onChange, onIfaceChange }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
function setOpt(key: string, value: string) {
|
||||||
|
if (!selected) return;
|
||||||
|
onChange({ ...selected, options: { ...selected.options, [key]: value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProp(key: keyof Widget, value: any) {
|
||||||
|
if (!selected) return;
|
||||||
|
onChange({ ...selected, [key]: value } as Widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
|
||||||
|
<div class="panel-header">
|
||||||
|
{!collapsed && <span class="panel-title">Properties</span>}
|
||||||
|
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||||
|
{collapsed ? '◀' : '▶'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div class="props-body">
|
||||||
|
{/* Interface-level properties (always shown) */}
|
||||||
|
<div class="props-section">
|
||||||
|
<div class="props-section-title">Canvas</div>
|
||||||
|
<Field label="Name">
|
||||||
|
<TextInput
|
||||||
|
value={iface.name}
|
||||||
|
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Width">
|
||||||
|
<input
|
||||||
|
class="prop-input prop-input-num"
|
||||||
|
type="number"
|
||||||
|
value={iface.w}
|
||||||
|
min={100}
|
||||||
|
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Height">
|
||||||
|
<input
|
||||||
|
class="prop-input prop-input-num"
|
||||||
|
type="number"
|
||||||
|
value={iface.h}
|
||||||
|
min={100}
|
||||||
|
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div class="props-section">
|
||||||
|
<div class="props-section-title">{selected.type}</div>
|
||||||
|
<Field label="X">
|
||||||
|
<input class="prop-input prop-input-num" type="number" value={selected.x}
|
||||||
|
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Y">
|
||||||
|
<input class="prop-input prop-input-num" type="number" value={selected.y}
|
||||||
|
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Width">
|
||||||
|
<input class="prop-input prop-input-num" type="number" value={selected.w} min={10}
|
||||||
|
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Height">
|
||||||
|
<input class="prop-input prop-input-num" type="number" value={selected.h} min={10}
|
||||||
|
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Signal */}
|
||||||
|
{selected.signals.length > 0 && (
|
||||||
|
<Field label="Signal">
|
||||||
|
<span class="prop-sig">{selected.signals[0].ds}:{selected.signals[0].name}</span>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Common options */}
|
||||||
|
<Field label="Label">
|
||||||
|
<TextInput
|
||||||
|
value={selected.options['label'] ?? ''}
|
||||||
|
onCommit={(v) => setOpt('label', v)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Type-specific options */}
|
||||||
|
{(selected.type === 'gauge' || selected.type === 'barh' || selected.type === 'barv') && (
|
||||||
|
<div>
|
||||||
|
<Field label="Min">
|
||||||
|
<TextInput value={selected.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Max">
|
||||||
|
<TextInput value={selected.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.type === 'led' && (
|
||||||
|
<Field label="Condition">
|
||||||
|
<TextInput value={selected.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.type === 'setvalue' && (
|
||||||
|
<Field label="Min">
|
||||||
|
<TextInput value={selected.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.type === 'plot' && (
|
||||||
|
<Field label="Plot type">
|
||||||
|
<select
|
||||||
|
class="prop-select"
|
||||||
|
value={selected.options['plotType'] ?? 'timeseries'}
|
||||||
|
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
<option value="timeseries">Time-series</option>
|
||||||
|
<option value="histogram">Histogram</option>
|
||||||
|
<option value="bar">Bar chart</option>
|
||||||
|
<option value="fft">FFT</option>
|
||||||
|
<option value="waterfall">Waterfall</option>
|
||||||
|
<option value="logic">Logic analyser</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.type === 'textlabel' && (
|
||||||
|
<div>
|
||||||
|
<Field label="Font size">
|
||||||
|
<TextInput value={selected.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Color">
|
||||||
|
<TextInput value={selected.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.type === 'image' && (
|
||||||
|
<Field label="URL">
|
||||||
|
<TextInput value={selected.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.type === 'link' && (
|
||||||
|
<div>
|
||||||
|
<Field label="Target">
|
||||||
|
<TextInput value={selected.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Label">
|
||||||
|
<TextInput value={selected.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selected && (
|
||||||
|
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import type { SignalRef } from './lib/types';
|
||||||
|
|
||||||
|
interface SignalInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
unit?: string;
|
||||||
|
description?: string;
|
||||||
|
writable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DsGroup {
|
||||||
|
ds: string;
|
||||||
|
signals: SignalInfo[];
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onDragStart?: (sig: SignalRef) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignalTree({ onDragStart }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [groups, setGroups] = useState<DsGroup[]>([]);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/datasources');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const dsList: { name: string }[] = await res.json();
|
||||||
|
const loaded: DsGroup[] = await Promise.all(
|
||||||
|
dsList.map(async ({ name }) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
||||||
|
const sigs: SignalInfo[] = r.ok ? await r.json() : [];
|
||||||
|
return { ds: name, signals: sigs, expanded: true };
|
||||||
|
} catch {
|
||||||
|
return { ds: name, signals: [], expanded: true };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setGroups(loaded);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load signals:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleGroup(ds: string) {
|
||||||
|
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lf = filter.toLowerCase();
|
||||||
|
const filtered = groups.map(g => ({
|
||||||
|
...g,
|
||||||
|
signals: lf ? g.signals.filter(s => s.name.toLowerCase().includes(lf) || (s.description ?? '').toLowerCase().includes(lf)) : g.signals,
|
||||||
|
})).filter(g => g.signals.length > 0 || !lf);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
|
||||||
|
<div class="panel-header">
|
||||||
|
{!collapsed && <span class="panel-title">Signals</span>}
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
title={collapsed ? 'Expand' : 'Collapse'}
|
||||||
|
>
|
||||||
|
{collapsed ? '▶' : '◀'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div class="signal-tree-body">
|
||||||
|
<div class="signal-tree-search">
|
||||||
|
<input
|
||||||
|
class="signal-filter"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter signals…"
|
||||||
|
value={filter}
|
||||||
|
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="panel-list signal-list">
|
||||||
|
{loading ? (
|
||||||
|
<p class="hint">Loading signals…</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p class="hint">No signals found.</p>
|
||||||
|
) : (
|
||||||
|
filtered.map(group => (
|
||||||
|
<div key={group.ds} class="signal-group">
|
||||||
|
<div class="signal-group-header" onClick={() => toggleGroup(group.ds)}>
|
||||||
|
<span class="signal-group-arrow">{group.expanded ? '▾' : '▸'}</span>
|
||||||
|
<span class="signal-group-name">{group.ds}</span>
|
||||||
|
<span class="signal-group-count">{group.signals.length}</span>
|
||||||
|
</div>
|
||||||
|
{group.expanded && group.signals.map(sig => (
|
||||||
|
<div
|
||||||
|
key={sig.name}
|
||||||
|
class="signal-item"
|
||||||
|
draggable
|
||||||
|
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||||
|
onDragStart={(e: DragEvent) => {
|
||||||
|
const ref: SignalRef = { ds: group.ds, name: sig.name };
|
||||||
|
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||||
|
onDragStart?.(ref);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="signal-name">{sig.name}</span>
|
||||||
|
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import InterfaceList from './InterfaceList';
|
||||||
|
import Canvas from './Canvas';
|
||||||
|
import { wsClient } from './lib/ws';
|
||||||
|
import { parseInterface } from './lib/xml';
|
||||||
|
import type { Interface } from './lib/types';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onEdit?: (iface?: Interface) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewMode({ onEdit }: Props) {
|
||||||
|
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null);
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null);
|
||||||
|
const [wsStatus, setWsStatus] = useState('connecting');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleLoad(xml: string) {
|
||||||
|
try {
|
||||||
|
setParseError(null);
|
||||||
|
setCurrentInterface(parseInterface(xml));
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(err instanceof Error ? err.message : 'Failed to parse interface file.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNavigate(interfaceId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const xml = await res.text();
|
||||||
|
handleLoad(xml);
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="view-mode">
|
||||||
|
<header class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="app-name">uopi</span>
|
||||||
|
{currentInterface && (
|
||||||
|
<span class="iface-name">{currentInterface.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-center">
|
||||||
|
{parseError && (
|
||||||
|
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<div class={`status-chip ${wsStatus}`}>
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
{wsStatus}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn-edit"
|
||||||
|
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
||||||
|
title="Switch to Edit mode"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<InterfaceList
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onSelect={async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
handleLoad(await res.text());
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Canvas iface={currentInterface} onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onPick: (type: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDGET_TYPES = [
|
||||||
|
{ type: 'textview', label: 'Text View', desc: 'Live value display' },
|
||||||
|
{ type: 'gauge', label: 'Gauge', desc: 'Circular arc gauge' },
|
||||||
|
{ type: 'barh', label: 'Bar (H)', desc: 'Horizontal bar' },
|
||||||
|
{ type: 'barv', label: 'Bar (V)', desc: 'Vertical bar' },
|
||||||
|
{ type: 'led', label: 'LED', desc: 'State indicator' },
|
||||||
|
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
|
||||||
|
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
|
||||||
|
{ type: 'button', label: 'Button', desc: 'Send command on click' },
|
||||||
|
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
|
||||||
|
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
|
||||||
|
{ type: 'image', label: 'Image', desc: 'Image from URL' },
|
||||||
|
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WidgetTypePicker({ x, y, onPick, onCancel }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="widget-picker-backdrop"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="widget-picker"
|
||||||
|
style={`left:${x}px;top:${y}px;`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="widget-picker-header">
|
||||||
|
<span>Choose widget type</span>
|
||||||
|
<button class="icon-btn" onClick={onCancel}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="widget-picker-list">
|
||||||
|
{WIDGET_TYPES.map(wt => (
|
||||||
|
<button
|
||||||
|
key={wt.type}
|
||||||
|
class="widget-picker-item"
|
||||||
|
onClick={() => onPick(wt.type)}
|
||||||
|
>
|
||||||
|
<span class="wpt-label">{wt.label}</span>
|
||||||
|
<span class="wpt-desc">{wt.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
-296
@@ -1,296 +0,0 @@
|
|||||||
:root {
|
|
||||||
--text: #6b6375;
|
|
||||||
--text-h: #08060d;
|
|
||||||
--bg: #fff;
|
|
||||||
--border: #e5e4e7;
|
|
||||||
--code-bg: #f4f3ec;
|
|
||||||
--accent: #aa3bff;
|
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2 {
|
|
||||||
font-family: var(--heading);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code,
|
|
||||||
.counter {
|
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#docs {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spacer {
|
|
||||||
height: 88px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
border-left-color: var(--border);
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
right: 0;
|
|
||||||
border-right-color: var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,98 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Interface, Widget } from './types';
|
|
||||||
import TextView from './widgets/TextView.svelte';
|
|
||||||
|
|
||||||
let { iface }: { iface: Interface | null } = $props();
|
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio ?? 1;
|
|
||||||
|
|
||||||
function componentForType(type: string): any {
|
|
||||||
switch (type) {
|
|
||||||
case 'textview': return TextView;
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="canvas-container"
|
|
||||||
style="--dpr: {dpr};"
|
|
||||||
>
|
|
||||||
{#if iface === null}
|
|
||||||
<div class="placeholder">
|
|
||||||
<p>Select an interface from the left panel, or import one.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="canvas-area">
|
|
||||||
{#each iface.widgets as widget (widget.id)}
|
|
||||||
{#if componentForType(widget.type) !== null}
|
|
||||||
{@const Comp = componentForType(widget.type)}
|
|
||||||
<Comp {widget} />
|
|
||||||
{:else}
|
|
||||||
<!-- Unknown widget type: grey placeholder box -->
|
|
||||||
<div
|
|
||||||
class="unknown-widget"
|
|
||||||
style="
|
|
||||||
left: {widget.x}px;
|
|
||||||
top: {widget.y}px;
|
|
||||||
width: {widget.w}px;
|
|
||||||
height: {widget.h}px;
|
|
||||||
"
|
|
||||||
title="Unknown widget type: {widget.type}"
|
|
||||||
>
|
|
||||||
<span class="unknown-label">{widget.type}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.canvas-container {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
overflow: auto;
|
|
||||||
background: #0f1117;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-area {
|
|
||||||
position: relative;
|
|
||||||
/* Large enough to accommodate absolutely-positioned widgets */
|
|
||||||
min-width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
width: max-content;
|
|
||||||
height: max-content;
|
|
||||||
padding: 1rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unknown-widget {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #1e2535;
|
|
||||||
border: 1px dashed #3d4f6e;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unknown-label {
|
|
||||||
color: #475569;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let count: number = $state(0)
|
|
||||||
const increment = () => {
|
|
||||||
count += 1
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button type="button" class="counter" onclick={increment}>
|
|
||||||
Count is {count}
|
|
||||||
</button>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Edit mode — coming in Phase 6
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="edit-mode">
|
|
||||||
<p>Edit mode — coming in Phase 6</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.edit-mode {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
background: #0f1117;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
interface ServerInterface {
|
|
||||||
name: string;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onLoad }: { onLoad: (xml: string) => void } = $props();
|
|
||||||
|
|
||||||
let collapsed = $state(false);
|
|
||||||
let interfaces = $state<ServerInterface[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let fileInput: HTMLInputElement | undefined = $state();
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/v1/interfaces');
|
|
||||||
if (resp.ok) {
|
|
||||||
interfaces = await resp.json();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server not reachable; leave empty list
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleCollapse() {
|
|
||||||
collapsed = !collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNewInterface() {
|
|
||||||
// Phase 6: edit mode
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerImport() {
|
|
||||||
fileInput?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFileChange(ev: Event) {
|
|
||||||
const input = ev.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const xml = await file.text();
|
|
||||||
onLoad(xml);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to read interface file:', err);
|
|
||||||
} finally {
|
|
||||||
// Reset so the same file can be re-imported
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="panel" class:collapsed>
|
|
||||||
<div class="panel-header">
|
|
||||||
{#if !collapsed}
|
|
||||||
<span class="panel-title">Interfaces</span>
|
|
||||||
{/if}
|
|
||||||
<button class="icon-btn" onclick={toggleCollapse} title={collapsed ? 'Expand panel' : 'Collapse panel'}>
|
|
||||||
{collapsed ? '▶' : '◀'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !collapsed}
|
|
||||||
<div class="panel-actions">
|
|
||||||
<button class="btn" onclick={handleNewInterface} title="New interface (Edit mode — Phase 6)">
|
|
||||||
+ New
|
|
||||||
</button>
|
|
||||||
<button class="btn" onclick={triggerImport} title="Import interface from XML file">
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
<!-- Hidden file input -->
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".xml,application/xml,text/xml"
|
|
||||||
style="display:none"
|
|
||||||
bind:this={fileInput}
|
|
||||||
onchange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-list">
|
|
||||||
{#if loading}
|
|
||||||
<p class="hint">Loading…</p>
|
|
||||||
{:else if interfaces.length === 0}
|
|
||||||
<p class="hint">No interfaces saved yet. Create one in Edit mode.</p>
|
|
||||||
{:else}
|
|
||||||
<ul>
|
|
||||||
{#each interfaces as iface}
|
|
||||||
<li class="iface-item">{iface.name ?? iface.id ?? 'Unnamed'}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.panel {
|
|
||||||
width: 260px;
|
|
||||||
min-width: 260px;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border-right: 1px solid #2d3748;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: width 0.2s ease, min-width 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel.collapsed {
|
|
||||||
width: 40px;
|
|
||||||
min-width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid #2d3748;
|
|
||||||
min-height: 40px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsed .panel-header {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #94a3b8;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #64748b;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover {
|
|
||||||
background: #2d3748;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border-bottom: 1px solid #2d3748;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
flex: 1;
|
|
||||||
background: #2d3748;
|
|
||||||
border: none;
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
color: #475569;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.75rem 0.75rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iface-item {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
color: #cbd5e1;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin: 1px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iface-item:hover {
|
|
||||||
background: #2d3748;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import InterfaceList from './InterfaceList.svelte';
|
|
||||||
import Canvas from './Canvas.svelte';
|
|
||||||
import { wsClient } from './ws';
|
|
||||||
import { parseInterface } from './xml';
|
|
||||||
import type { Interface } from './types';
|
|
||||||
|
|
||||||
let currentInterface = $state<Interface | null>(null);
|
|
||||||
let parseError = $state<string | null>(null);
|
|
||||||
|
|
||||||
const wsStatus = wsClient.status;
|
|
||||||
|
|
||||||
function handleLoad(xml: string) {
|
|
||||||
try {
|
|
||||||
parseError = null;
|
|
||||||
currentInterface = parseInterface(xml);
|
|
||||||
} catch (err) {
|
|
||||||
parseError = err instanceof Error ? err.message : 'Failed to parse interface file.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="view-mode">
|
|
||||||
<!-- Top toolbar -->
|
|
||||||
<header class="toolbar">
|
|
||||||
<div class="toolbar-left">
|
|
||||||
<span class="app-name">uopi</span>
|
|
||||||
{#if currentInterface}
|
|
||||||
<span class="iface-name">{currentInterface.name}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="toolbar-center">
|
|
||||||
{#if parseError}
|
|
||||||
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="toolbar-right">
|
|
||||||
<div class="status-chip" class:connected={$wsStatus === 'connected'} class:disconnected={$wsStatus === 'disconnected'} class:connecting={$wsStatus === 'connecting'}>
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
{$wsStatus}
|
|
||||||
</div>
|
|
||||||
<button class="btn-edit" disabled title="Edit mode — coming in Phase 6">Edit</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main content row -->
|
|
||||||
<div class="content">
|
|
||||||
<InterfaceList onLoad={handleLoad} />
|
|
||||||
<Canvas iface={currentInterface} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.view-mode {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0f1117;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 1rem;
|
|
||||||
height: 44px;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border-bottom: 1px solid #2d3748;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-center {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #60a5fa;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iface-name {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #94a3b8;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parse-error {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: #f87171;
|
|
||||||
background: rgba(248, 113, 113, 0.1);
|
|
||||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #1e293b;
|
|
||||||
color: #94a3b8;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-chip.connected {
|
|
||||||
background: #14532d;
|
|
||||||
color: #86efac;
|
|
||||||
border-color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-chip.disconnected {
|
|
||||||
background: #7f1d1d;
|
|
||||||
color: #fca5a5;
|
|
||||||
border-color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-chip.connecting {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #fbbf24;
|
|
||||||
border-color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background: #2d3748;
|
|
||||||
border: none;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.25rem 0.7rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Minimal reactive store — Svelte-compatible interface, no Svelte dependency.
|
||||||
|
export interface Readable<T> {
|
||||||
|
subscribe(run: (value: T) => void): () => void;
|
||||||
|
}
|
||||||
|
export interface Writable<T> extends Readable<T> {
|
||||||
|
set(value: T): void;
|
||||||
|
update(fn: (value: T) => T): void;
|
||||||
|
get(): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writable<T>(initial: T): Writable<T> {
|
||||||
|
let value = initial;
|
||||||
|
const subs = new Set<(v: T) => void>();
|
||||||
|
return {
|
||||||
|
subscribe(fn) { subs.add(fn); fn(value); return () => subs.delete(fn); },
|
||||||
|
set(v) { value = v; subs.forEach(fn => fn(v)); },
|
||||||
|
update(fn) { this.set(fn(value)); },
|
||||||
|
get() { return value; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readable<T>(initial: T, start?: (set: (v: T) => void) => () => void): Readable<T> {
|
||||||
|
const w = writable(initial);
|
||||||
|
if (start) {
|
||||||
|
let stop: (() => void) | null = null;
|
||||||
|
let subscribers = 0;
|
||||||
|
return {
|
||||||
|
subscribe(fn) {
|
||||||
|
subscribers++;
|
||||||
|
if (subscribers === 1) stop = start(w.set.bind(w));
|
||||||
|
const unsub = w.subscribe(fn);
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
subscribers--;
|
||||||
|
if (subscribers === 0 && stop) { stop(); stop = null; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readable, writable, type Readable } from 'svelte/store';
|
import { readable, writable, type Readable } from './store';
|
||||||
import { wsClient } from './ws';
|
import { wsClient } from './ws';
|
||||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export interface Widget {
|
|||||||
|
|
||||||
// A complete HMI interface definition
|
// A complete HMI interface definition
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
version: number;
|
version: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
widgets: Widget[];
|
widgets: Widget[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSignalStore, getMetaStore } from '../stores';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
|
||||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
|
||||||
|
|
||||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
|
||||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
|
||||||
|
|
||||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
|
||||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
|
||||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
|
||||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
|
||||||
|
|
||||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
|
||||||
const qualityColor = $derived(() => {
|
|
||||||
switch (quality) {
|
|
||||||
case 'good': return '#4ade80';
|
|
||||||
case 'uncertain': return '#fbbf24';
|
|
||||||
case 'bad': return '#f87171';
|
|
||||||
default: return '#6b7280';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawValue = $derived(() => {
|
|
||||||
const v = signalValue?.value;
|
|
||||||
if (v === null || v === undefined) return null;
|
|
||||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayValue = $derived(() => {
|
|
||||||
const v = rawValue();
|
|
||||||
if (v === null || isNaN(v)) return '---';
|
|
||||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fillPercent = $derived(() => {
|
|
||||||
const v = rawValue();
|
|
||||||
if (v === null || isNaN(v)) return 0;
|
|
||||||
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
|
|
||||||
return Math.max(0, Math.min(100, frac * 100));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="barh"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
>
|
|
||||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
|
||||||
{#if label}
|
|
||||||
<div class="bar-label">{label}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="bar-track">
|
|
||||||
<div class="bar-fill" style="width: {fillPercent()}%;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bar-value">
|
|
||||||
<span class="value">{displayValue()}</span>
|
|
||||||
{#if unit}<span class="unit">{unit}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.barh {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 3px;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-dot {
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
right: 5px;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #94a3b8;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-track {
|
|
||||||
width: 100%;
|
|
||||||
height: 12px;
|
|
||||||
background: #2d3748;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: #4a9eff;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-value {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unit {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSignalStore, getMetaStore } from '../stores';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
|
||||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
|
||||||
|
|
||||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
|
||||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
|
||||||
|
|
||||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
|
||||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
|
||||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
|
||||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
|
||||||
|
|
||||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
|
||||||
const qualityColor = $derived(() => {
|
|
||||||
switch (quality) {
|
|
||||||
case 'good': return '#4ade80';
|
|
||||||
case 'uncertain': return '#fbbf24';
|
|
||||||
case 'bad': return '#f87171';
|
|
||||||
default: return '#6b7280';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawValue = $derived(() => {
|
|
||||||
const v = signalValue?.value;
|
|
||||||
if (v === null || v === undefined) return null;
|
|
||||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayValue = $derived(() => {
|
|
||||||
const v = rawValue();
|
|
||||||
if (v === null || isNaN(v)) return '---';
|
|
||||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fillPercent = $derived(() => {
|
|
||||||
const v = rawValue();
|
|
||||||
if (v === null || isNaN(v)) return 0;
|
|
||||||
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
|
|
||||||
return Math.max(0, Math.min(100, frac * 100));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="barv"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
>
|
|
||||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
|
||||||
{#if label}
|
|
||||||
<div class="bar-label">{label}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="bar-value">
|
|
||||||
<span class="value">{displayValue()}</span>
|
|
||||||
{#if unit}<span class="unit">{unit}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<div class="bar-track">
|
|
||||||
<div class="bar-fill" style="height: {fillPercent()}%;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.barv {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 3px;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-dot {
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
right: 5px;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #94a3b8;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-value {
|
|
||||||
display: flex;
|
|
||||||
gap: 3px;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unit {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-track {
|
|
||||||
width: 24px;
|
|
||||||
flex: 1;
|
|
||||||
background: #2d3748;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-fill {
|
|
||||||
width: 100%;
|
|
||||||
background: #4a9eff;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: height 0.15s ease;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { wsClient } from '../ws';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const label = $derived(widget.options['label'] ?? 'Button');
|
|
||||||
const valueOpt = $derived(widget.options['value'] ?? '1');
|
|
||||||
const confirm = $derived(widget.options['confirm'] === 'true');
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (!sigRef) return;
|
|
||||||
// Parse as number if possible, otherwise send as string
|
|
||||||
const parsed = parseFloat(valueOpt);
|
|
||||||
const val = isNaN(parsed) ? valueOpt : parsed;
|
|
||||||
|
|
||||||
const doWrite = () => wsClient.write(sigRef, val);
|
|
||||||
if (confirm) {
|
|
||||||
if (window.confirm(`Send ${label}?`)) doWrite();
|
|
||||||
} else {
|
|
||||||
doWrite();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="button-widget"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
>
|
|
||||||
<button class="btn" onclick={handleClick}>{label}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.button-widget {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: #1e3a5f;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: 1px solid #2d5a9e;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
background: #1d4ed8;
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSignalStore, getMetaStore } from '../stores';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
|
||||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
|
||||||
|
|
||||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
|
||||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
|
||||||
|
|
||||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
|
||||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
|
||||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
|
||||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
|
||||||
const thresholdLow = $derived(widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null);
|
|
||||||
const thresholdHigh = $derived(widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null);
|
|
||||||
|
|
||||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
|
||||||
const qualityColor = $derived(() => {
|
|
||||||
switch (quality) {
|
|
||||||
case 'good': return '#4ade80';
|
|
||||||
case 'uncertain': return '#fbbf24';
|
|
||||||
case 'bad': return '#f87171';
|
|
||||||
default: return '#6b7280';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawValue = $derived(() => {
|
|
||||||
const v = signalValue?.value;
|
|
||||||
if (v === null || v === undefined) return null;
|
|
||||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayValue = $derived(() => {
|
|
||||||
const v = rawValue();
|
|
||||||
if (v === null || isNaN(v)) return '---';
|
|
||||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Arc: -135deg to +135deg (270deg total)
|
|
||||||
const START_DEG = -135;
|
|
||||||
const END_DEG = 135;
|
|
||||||
const TOTAL_DEG = 270;
|
|
||||||
|
|
||||||
const cx = 50;
|
|
||||||
const cy = 54;
|
|
||||||
const r = 38;
|
|
||||||
|
|
||||||
function degToRad(deg: number) {
|
|
||||||
return (deg * Math.PI) / 180;
|
|
||||||
}
|
|
||||||
|
|
||||||
function polarToXY(deg: number, radius: number) {
|
|
||||||
const rad = degToRad(deg);
|
|
||||||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function arcPath(startDeg: number, endDeg: number, radius: number) {
|
|
||||||
const s = polarToXY(startDeg, radius);
|
|
||||||
const e = polarToXY(endDeg, radius);
|
|
||||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
|
||||||
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function valueToDeg(v: number) {
|
|
||||||
const clamped = Math.max(minVal, Math.min(maxVal, v));
|
|
||||||
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
|
|
||||||
return START_DEG + frac * TOTAL_DEG;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fillColor = $derived(() => {
|
|
||||||
const v = rawValue();
|
|
||||||
if (v === null) return '#4a9eff';
|
|
||||||
if (thresholdHigh !== null && v >= thresholdHigh) return '#ef4444';
|
|
||||||
if (thresholdLow !== null && v <= thresholdLow) return '#f59e0b';
|
|
||||||
return '#4a9eff';
|
|
||||||
});
|
|
||||||
|
|
||||||
const needleDeg = $derived(() => {
|
|
||||||
const v = rawValue();
|
|
||||||
if (v === null) return START_DEG;
|
|
||||||
return valueToDeg(v);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fillArcPath = $derived(() => {
|
|
||||||
const nd = needleDeg();
|
|
||||||
if (nd <= START_DEG) return '';
|
|
||||||
return arcPath(START_DEG, nd, r);
|
|
||||||
});
|
|
||||||
|
|
||||||
const bgArcPath = $derived(() => arcPath(START_DEG, END_DEG, r));
|
|
||||||
|
|
||||||
const needlePos = $derived(() => polarToXY(needleDeg(), r - 4));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="gauge"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
>
|
|
||||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
|
||||||
<svg viewBox="0 0 100 80" class="gauge-svg">
|
|
||||||
<!-- Background arc -->
|
|
||||||
<path d={bgArcPath()} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
|
|
||||||
<!-- Filled arc -->
|
|
||||||
{#if fillArcPath()}
|
|
||||||
<path d={fillArcPath()} fill="none" stroke={fillColor()} stroke-width="6" stroke-linecap="round" />
|
|
||||||
{/if}
|
|
||||||
<!-- Needle dot -->
|
|
||||||
<circle cx={needlePos().x} cy={needlePos().y} r="3" fill={fillColor()} />
|
|
||||||
<!-- Center value -->
|
|
||||||
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
|
|
||||||
{#if unit}
|
|
||||||
<text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>
|
|
||||||
{/if}
|
|
||||||
<!-- Min/Max labels -->
|
|
||||||
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
|
|
||||||
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
|
||||||
</svg>
|
|
||||||
{#if label}
|
|
||||||
<div class="gauge-label">{label}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.gauge {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-dot {
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
right: 5px;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gauge-svg {
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gauge-value {
|
|
||||||
fill: #e2e8f0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gauge-unit {
|
|
||||||
fill: #64748b;
|
|
||||||
font-size: 6px;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gauge-range {
|
|
||||||
fill: #475569;
|
|
||||||
font-size: 6px;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gauge-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #94a3b8;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSignalStore } from '../stores';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
|
||||||
|
|
||||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
|
||||||
|
|
||||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
|
||||||
const condition = $derived(widget.options['condition'] ?? 'value > 0');
|
|
||||||
const colorTrue = $derived(widget.options['colorTrue'] ?? '#22c55e');
|
|
||||||
const colorFalse = $derived(widget.options['colorFalse'] ?? '#ef4444');
|
|
||||||
|
|
||||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
|
||||||
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
|
|
||||||
|
|
||||||
function evaluateCondition(expr: string, v: any): boolean {
|
|
||||||
// Safety check: reject dangerous patterns
|
|
||||||
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-new-func
|
|
||||||
return new Function('value', 'return ' + expr)(v);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ledOn = $derived(() => {
|
|
||||||
const v = signalValue?.value;
|
|
||||||
if (v === null || v === undefined) return false;
|
|
||||||
return evaluateCondition(condition, v);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ledColor = $derived(() => ledOn() ? colorTrue : colorFalse);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="led-widget"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="led-circle"
|
|
||||||
class:blink={isUncertain}
|
|
||||||
style="background: {ledColor()}; box-shadow: 0 0 8px {ledColor()}88;"
|
|
||||||
></div>
|
|
||||||
{#if label}
|
|
||||||
<div class="led-label">{label}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.led-widget {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 4px;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.led-circle {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.led-circle.blink {
|
|
||||||
animation: blink-slow 1.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink-slow {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.led-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #94a3b8;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSignalStore } from '../stores';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
|
||||||
|
|
||||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
|
||||||
|
|
||||||
const bits = $derived(parseInt(widget.options['bits'] ?? '8', 10));
|
|
||||||
const labelsRaw = $derived(widget.options['labels'] ?? '');
|
|
||||||
const colorsTrueRaw = $derived(widget.options['colorsTrue'] ?? '');
|
|
||||||
const colorsFalseRaw = $derived(widget.options['colorsFalse'] ?? '');
|
|
||||||
|
|
||||||
const labelArr = $derived(labelsRaw ? labelsRaw.split(',') : []);
|
|
||||||
const colorsTrueArr = $derived(colorsTrueRaw ? colorsTrueRaw.split(',') : []);
|
|
||||||
const colorsFalseArr = $derived(colorsFalseRaw ? colorsFalseRaw.split(',') : []);
|
|
||||||
|
|
||||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
|
||||||
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
|
|
||||||
|
|
||||||
const intValue = $derived(() => {
|
|
||||||
const v = signalValue?.value;
|
|
||||||
if (v === null || v === undefined) return 0;
|
|
||||||
return typeof v === 'number' ? Math.floor(v) : parseInt(String(v), 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
function getBit(val: number, bit: number): boolean {
|
|
||||||
return (val & (1 << bit)) !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bitStates = $derived(() => {
|
|
||||||
const val = intValue();
|
|
||||||
return Array.from({ length: bits }, (_, i) => getBit(val, i));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="multiled-widget"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
>
|
|
||||||
{#each bitStates() as on, i}
|
|
||||||
{@const trueColor = colorsTrueArr[i] ?? '#22c55e'}
|
|
||||||
{@const falseColor = colorsFalseArr[i] ?? '#ef4444'}
|
|
||||||
{@const color = on ? trueColor : falseColor}
|
|
||||||
{@const bitLabel = labelArr[i] ?? String(i)}
|
|
||||||
<div class="bit-item">
|
|
||||||
<div
|
|
||||||
class="led-circle"
|
|
||||||
class:blink={isUncertain}
|
|
||||||
style="background: {color}; box-shadow: 0 0 6px {color}88;"
|
|
||||||
></div>
|
|
||||||
<div class="bit-label">{bitLabel}</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.multiled-widget {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 4px;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 6px;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bit-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.led-circle {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.led-circle.blink {
|
|
||||||
animation: blink-slow 1.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink-slow {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bit-label {
|
|
||||||
font-size: 0.6rem;
|
|
||||||
color: #64748b;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import { getSignalStore } from '../stores';
|
|
||||||
import type { Widget, SignalRef } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const plotType = $derived(widget.options['plotType'] ?? 'timeseries');
|
|
||||||
const timeWindow = $derived(parseFloat(widget.options['timeWindow'] ?? '60'));
|
|
||||||
const legendPos = $derived(widget.options['legend'] ?? 'bottom');
|
|
||||||
const yMinOpt = $derived(widget.options['yMin'] ?? 'auto');
|
|
||||||
const yMaxOpt = $derived(widget.options['yMax'] ?? 'auto');
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let chartEl: HTMLDivElement;
|
|
||||||
|
|
||||||
// ── Shared per-signal data buffers ────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface SeriesBuffer {
|
|
||||||
timestamps: number[]; // Unix seconds
|
|
||||||
values: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filled on mount — up to 10 signals
|
|
||||||
let buffers: SeriesBuffer[] = [];
|
|
||||||
let unsubs: (() => void)[] = [];
|
|
||||||
|
|
||||||
// ── uPlot (timeseries) ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let uplotInstance: any = null;
|
|
||||||
let uplotImported = false;
|
|
||||||
|
|
||||||
// ── ECharts (other types) ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let echartsInstance: any = null;
|
|
||||||
let echartsImported = false;
|
|
||||||
|
|
||||||
// Histogram per-series: accumulate raw values, bucket on render
|
|
||||||
let histValues: number[][] = [];
|
|
||||||
|
|
||||||
const RING_MAX = 4000;
|
|
||||||
|
|
||||||
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
|
|
||||||
buf.timestamps.push(ts);
|
|
||||||
buf.values.push(val);
|
|
||||||
if (buf.timestamps.length > RING_MAX) {
|
|
||||||
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
|
|
||||||
buf.values.splice(0, buf.values.length - RING_MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function windowedSlice(buf: SeriesBuffer, windowSec: number): { ts: number[]; vals: number[] } {
|
|
||||||
const now = Date.now() / 1000;
|
|
||||||
const cutoff = now - windowSec;
|
|
||||||
const start = buf.timestamps.findIndex((t) => t >= cutoff);
|
|
||||||
if (start === -1) return { ts: [], vals: [] };
|
|
||||||
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── uPlot rendering ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function initUplot() {
|
|
||||||
if (uplotImported) return;
|
|
||||||
uplotImported = true;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const uPlot = (await import('uplot')).default;
|
|
||||||
|
|
||||||
const signals = widget.signals;
|
|
||||||
const seriesConf = [
|
|
||||||
{},
|
|
||||||
...signals.map((s, i) => ({
|
|
||||||
label: s.name,
|
|
||||||
stroke: s.color ?? defaultColors[i % defaultColors.length],
|
|
||||||
width: 1.5,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const legendShow = legendPos !== 'none';
|
|
||||||
|
|
||||||
uplotInstance = new uPlot(
|
|
||||||
{
|
|
||||||
width: chartEl.clientWidth || widget.w - 2,
|
|
||||||
height: chartEl.clientHeight || widget.h - 40,
|
|
||||||
series: seriesConf,
|
|
||||||
legend: { show: legendShow },
|
|
||||||
axes: [
|
|
||||||
{ space: 40 },
|
|
||||||
{
|
|
||||||
min: yMinOpt === 'auto' ? undefined : parseFloat(yMinOpt),
|
|
||||||
max: yMaxOpt === 'auto' ? undefined : parseFloat(yMaxOpt),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
scales: {
|
|
||||||
x: { time: true },
|
|
||||||
y: {
|
|
||||||
range: yMinOpt === 'auto' && yMaxOpt === 'auto'
|
|
||||||
? undefined
|
|
||||||
: [
|
|
||||||
yMinOpt === 'auto' ? null : parseFloat(yMinOpt),
|
|
||||||
yMaxOpt === 'auto' ? null : parseFloat(yMaxOpt),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buildUplotData(),
|
|
||||||
chartEl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUplotData(): any[] {
|
|
||||||
if (buffers.length === 0) return [[]];
|
|
||||||
// Use longest timestamps set (first signal)
|
|
||||||
const tw = timeWindow;
|
|
||||||
const allTs = new Set<number>();
|
|
||||||
buffers.forEach((b) => {
|
|
||||||
const { ts } = windowedSlice(b, tw);
|
|
||||||
ts.forEach((t) => allTs.add(t));
|
|
||||||
});
|
|
||||||
const sortedTs = Array.from(allTs).sort((a, b) => a - b);
|
|
||||||
if (sortedTs.length === 0) return [[]];
|
|
||||||
|
|
||||||
const series: any[] = [sortedTs];
|
|
||||||
buffers.forEach((b) => {
|
|
||||||
const tsMap = new Map<number, number>();
|
|
||||||
const { ts, vals } = windowedSlice(b, tw);
|
|
||||||
ts.forEach((t, i) => tsMap.set(t, vals[i]));
|
|
||||||
series.push(sortedTs.map((t) => tsMap.get(t) ?? null));
|
|
||||||
});
|
|
||||||
return series;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUplot() {
|
|
||||||
if (!uplotInstance) return;
|
|
||||||
uplotInstance.setData(buildUplotData());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ECharts rendering ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function initEcharts() {
|
|
||||||
if (echartsImported) return;
|
|
||||||
echartsImported = true;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const echarts = await import('echarts');
|
|
||||||
echartsInstance = echarts.init(chartEl, 'dark');
|
|
||||||
updateEcharts(echarts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEchartsOption(echarts?: any): any {
|
|
||||||
const signals = widget.signals;
|
|
||||||
const legendShow = legendPos !== 'none';
|
|
||||||
|
|
||||||
switch (plotType) {
|
|
||||||
case 'fft': {
|
|
||||||
return {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
|
||||||
xAxis: { type: 'category', name: 'Freq Index', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
|
|
||||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
|
|
||||||
series: buffers.map((b, i) => ({
|
|
||||||
name: signals[i]?.name ?? `S${i}`,
|
|
||||||
type: 'line',
|
|
||||||
data: b.values.length ? b.values[b.values.length - 1] : [],
|
|
||||||
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
|
||||||
showSymbol: false,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'histogram': {
|
|
||||||
const buckets = buildHistogram();
|
|
||||||
return {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
|
||||||
xAxis: { type: 'category', data: buckets.labels, axisLabel: { color: '#64748b' } },
|
|
||||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
|
||||||
series: buckets.series.map((d, i) => ({
|
|
||||||
name: signals[i]?.name ?? `S${i}`,
|
|
||||||
type: 'bar',
|
|
||||||
data: d,
|
|
||||||
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'bar': {
|
|
||||||
return {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
|
||||||
xAxis: { type: 'category', data: signals.map((s) => s.name), axisLabel: { color: '#64748b' } },
|
|
||||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
|
||||||
series: [{
|
|
||||||
type: 'bar',
|
|
||||||
data: buffers.map((b, i) => ({
|
|
||||||
value: b.values.length ? b.values[b.values.length - 1] : 0,
|
|
||||||
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
|
||||||
})),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'logic': {
|
|
||||||
return {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
|
||||||
xAxis: { type: 'value', axisLabel: { color: '#64748b' }, min: 'dataMin', max: 'dataMax' },
|
|
||||||
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLabel: { color: '#64748b' } },
|
|
||||||
series: buffers.map((b, i) => {
|
|
||||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
|
||||||
return {
|
|
||||||
name: signals[i]?.name ?? `S${i}`,
|
|
||||||
type: 'line',
|
|
||||||
step: 'end',
|
|
||||||
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
|
||||||
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
|
||||||
showSymbol: false,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'waterfall': {
|
|
||||||
// Display last N rows as a heatmap-style waterfall
|
|
||||||
const ROWS = 32;
|
|
||||||
const history = buffers[0];
|
|
||||||
const rows: number[][] = [];
|
|
||||||
const step = Math.max(1, Math.floor(history.values.length / ROWS));
|
|
||||||
for (let i = 0; i < ROWS; i++) {
|
|
||||||
const idx = Math.min(history.values.length - 1 - (ROWS - 1 - i) * step, history.values.length - 1);
|
|
||||||
if (idx < 0) {
|
|
||||||
rows.push([]);
|
|
||||||
} else {
|
|
||||||
const v = history.values[idx];
|
|
||||||
rows.push(Array.isArray(v) ? v : [v]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const flatData: [number, number, number][] = [];
|
|
||||||
rows.forEach((row, ri) => row.forEach((val, ci) => flatData.push([ci, ri, val])));
|
|
||||||
return {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
|
||||||
xAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
|
||||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
|
||||||
series: [{ type: 'heatmap', data: flatData }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHistogram(): { labels: string[]; series: number[][] } {
|
|
||||||
const BUCKETS = 20;
|
|
||||||
const result: number[][] = buffers.map(() => new Array(BUCKETS).fill(0));
|
|
||||||
let globalMin = Infinity, globalMax = -Infinity;
|
|
||||||
histValues.forEach((vals) => {
|
|
||||||
vals.forEach((v) => {
|
|
||||||
if (v < globalMin) globalMin = v;
|
|
||||||
if (v > globalMax) globalMax = v;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (!isFinite(globalMin) || !isFinite(globalMax) || globalMin === globalMax) {
|
|
||||||
return { labels: [], series: result };
|
|
||||||
}
|
|
||||||
const range = globalMax - globalMin;
|
|
||||||
const bucketSize = range / BUCKETS;
|
|
||||||
histValues.forEach((vals, si) => {
|
|
||||||
vals.forEach((v) => {
|
|
||||||
const idx = Math.min(BUCKETS - 1, Math.floor((v - globalMin) / bucketSize));
|
|
||||||
result[si][idx]++;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const labels = Array.from({ length: BUCKETS }, (_, i) =>
|
|
||||||
(globalMin + i * bucketSize).toPrecision(3),
|
|
||||||
);
|
|
||||||
return { labels, series: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEcharts(echarts?: any) {
|
|
||||||
if (!echartsInstance) return;
|
|
||||||
echartsInstance.setOption(buildEchartsOption(echarts), { notMerge: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mount & cleanup ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const defaultColors = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const signals = widget.signals;
|
|
||||||
buffers = signals.map(() => ({ timestamps: [], values: [] }));
|
|
||||||
histValues = signals.map(() => []);
|
|
||||||
|
|
||||||
signals.forEach((sig: SignalRef & { color?: string }, i: number) => {
|
|
||||||
const store = getSignalStore(sig);
|
|
||||||
const unsub = store.subscribe((sv) => {
|
|
||||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
|
||||||
const ts = new Date(sv.ts).getTime() / 1000;
|
|
||||||
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
|
|
||||||
if (!isNaN(v)) {
|
|
||||||
pushSample(buffers[i], ts, v);
|
|
||||||
if (plotType === 'histogram') {
|
|
||||||
histValues[i].push(v);
|
|
||||||
if (histValues[i].length > RING_MAX) histValues[i].splice(0, histValues[i].length - RING_MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Trigger render
|
|
||||||
if (plotType === 'timeseries') {
|
|
||||||
updateUplot();
|
|
||||||
} else {
|
|
||||||
updateEcharts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
unsubs.push(unsub);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (plotType === 'timeseries') {
|
|
||||||
await initUplot();
|
|
||||||
} else {
|
|
||||||
await initEcharts();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle resize
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
if (uplotInstance) {
|
|
||||||
uplotInstance.setSize({ width: chartEl.clientWidth, height: chartEl.clientHeight });
|
|
||||||
}
|
|
||||||
if (echartsInstance) {
|
|
||||||
echartsInstance.resize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ro.observe(containerEl);
|
|
||||||
unsubs.push(() => ro.disconnect());
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
unsubs.forEach((u) => u());
|
|
||||||
if (uplotInstance) uplotInstance.destroy();
|
|
||||||
if (echartsInstance) echartsInstance.dispose();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
{#if plotType === 'timeseries'}
|
|
||||||
<link rel="stylesheet" href="/node_modules/uplot/dist/uPlot.min.css" />
|
|
||||||
{/if}
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="plot-widget"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
bind:this={containerEl}
|
|
||||||
>
|
|
||||||
<div class="chart-area" bind:this={chartEl}></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.plot-widget {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-area {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure uPlot fills the container */
|
|
||||||
.chart-area :global(.uplot) {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSignalStore, getMetaStore } from '../stores';
|
|
||||||
import { wsClient } from '../ws';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
|
||||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
|
||||||
|
|
||||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
|
||||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
|
||||||
|
|
||||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
|
||||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
|
||||||
const confirm = $derived(widget.options['confirm'] === 'true');
|
|
||||||
const isNumeric = $derived(signalMeta ? signalMeta.type !== 'TypeString' : true);
|
|
||||||
|
|
||||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
|
||||||
const qualityColor = $derived(() => {
|
|
||||||
switch (quality) {
|
|
||||||
case 'good': return '#4ade80';
|
|
||||||
case 'uncertain': return '#fbbf24';
|
|
||||||
case 'bad': return '#f87171';
|
|
||||||
default: return '#6b7280';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayValue = $derived(() => {
|
|
||||||
const v = signalValue?.value;
|
|
||||||
if (v === null || v === undefined) return '---';
|
|
||||||
if (typeof v === 'number') {
|
|
||||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
|
||||||
}
|
|
||||||
return String(v);
|
|
||||||
});
|
|
||||||
|
|
||||||
let inputValue = $state('');
|
|
||||||
|
|
||||||
function handleSet() {
|
|
||||||
if (!sigRef) return;
|
|
||||||
const raw = inputValue.trim();
|
|
||||||
if (!raw) return;
|
|
||||||
|
|
||||||
const doWrite = () => {
|
|
||||||
const val = isNumeric ? parseFloat(raw) : raw;
|
|
||||||
wsClient.write(sigRef, val);
|
|
||||||
inputValue = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (confirm) {
|
|
||||||
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
|
|
||||||
} else {
|
|
||||||
doWrite();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') handleSet();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="setvalue"
|
|
||||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
|
||||||
>
|
|
||||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
|
||||||
<span class="sv-label">{label}:</span>
|
|
||||||
{#if isNumeric}
|
|
||||||
<input
|
|
||||||
class="sv-input"
|
|
||||||
type="number"
|
|
||||||
bind:value={inputValue}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
placeholder="value"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<input
|
|
||||||
class="sv-input"
|
|
||||||
type="text"
|
|
||||||
bind:value={inputValue}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
placeholder="value"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<span class="sv-current">{displayValue()}</span>
|
|
||||||
{#if unit}<span class="sv-unit">{unit}</span>{/if}
|
|
||||||
<button class="sv-btn" onclick={handleSet}>Set</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.setvalue {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 0 8px;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-label {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-input {
|
|
||||||
width: 80px;
|
|
||||||
background: #0f1117;
|
|
||||||
border: 1px solid #3d4f6e;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 2px 5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #4a9eff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-current {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #e2e8f0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-unit {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #64748b;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-btn {
|
|
||||||
background: #2563eb;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-btn:hover {
|
|
||||||
background: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-btn:active {
|
|
||||||
background: #1e40af;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSignalStore, getMetaStore } from '../stores';
|
|
||||||
import type { Widget } from '../types';
|
|
||||||
|
|
||||||
let { widget }: { widget: Widget } = $props();
|
|
||||||
|
|
||||||
const sigRef = $derived(widget.signals[0]);
|
|
||||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
|
||||||
|
|
||||||
// Reactive stores — re-created when sigRef changes
|
|
||||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
|
||||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
|
||||||
|
|
||||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
|
||||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
|
||||||
|
|
||||||
const displayValue = $derived(() => {
|
|
||||||
if (!signalValue || signalValue.value === null || signalValue.value === undefined) {
|
|
||||||
return '---';
|
|
||||||
}
|
|
||||||
const v = signalValue.value;
|
|
||||||
if (typeof v === 'number') {
|
|
||||||
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
|
||||||
}
|
|
||||||
return String(v);
|
|
||||||
});
|
|
||||||
|
|
||||||
const unit = $derived(signalMeta?.unit ?? '');
|
|
||||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
|
||||||
|
|
||||||
const qualityColor = $derived(() => {
|
|
||||||
switch (quality) {
|
|
||||||
case 'good': return '#4ade80';
|
|
||||||
case 'uncertain': return '#fbbf24';
|
|
||||||
case 'bad': return '#f87171';
|
|
||||||
default: return '#6b7280';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="textview"
|
|
||||||
style="
|
|
||||||
left: {widget.x}px;
|
|
||||||
top: {widget.y}px;
|
|
||||||
width: {widget.w}px;
|
|
||||||
height: {widget.h}px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
|
||||||
<span class="label">{label}:</span>
|
|
||||||
<span class="value">{displayValue()}</span>
|
|
||||||
{#if unit}
|
|
||||||
<span class="unit">{unit}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.textview {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4em;
|
|
||||||
padding: 0 0.6em;
|
|
||||||
background: #1a1f2e;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-family: ui-monospace, Consolas, monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #e2e8f0;
|
|
||||||
min-width: 4ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unit {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { readable, writable, type Readable } from 'svelte/store';
|
import { readable, writable, type Readable } from './store';
|
||||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+39
-1
@@ -1,5 +1,40 @@
|
|||||||
import type { Interface, Widget, SignalRef } from './types';
|
import type { Interface, Widget, SignalRef } from './types';
|
||||||
|
|
||||||
|
/** Escape a string for use as an XML attribute value. */
|
||||||
|
function xmlEsc(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize an Interface object to an XML string. */
|
||||||
|
export function serializeInterface(iface: Interface): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||||
|
lines.push(
|
||||||
|
`<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` +
|
||||||
|
`version="${iface.version}" width="${iface.w}" height="${iface.h}">`
|
||||||
|
);
|
||||||
|
for (const w of iface.widgets) {
|
||||||
|
lines.push(
|
||||||
|
` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` +
|
||||||
|
`x="${w.x}" y="${w.y}" w="${w.w}" h="${w.h}">`
|
||||||
|
);
|
||||||
|
for (const sig of w.signals) {
|
||||||
|
const colorAttr = sig.color ? ` color="${xmlEsc(sig.color)}"` : '';
|
||||||
|
lines.push(` <signal ds="${xmlEsc(sig.ds)}" name="${xmlEsc(sig.name)}"${colorAttr}/>`);
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(w.options)) {
|
||||||
|
lines.push(` <option key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
|
||||||
|
}
|
||||||
|
lines.push(` </widget>`);
|
||||||
|
}
|
||||||
|
lines.push(`</interface>`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an interface XML string into an Interface object.
|
* Parse an interface XML string into an Interface object.
|
||||||
*
|
*
|
||||||
@@ -26,8 +61,11 @@ export function parseInterface(xml: string): Interface {
|
|||||||
throw new Error(`Expected root element <interface>, got <${root.tagName}>`);
|
throw new Error(`Expected root element <interface>, got <${root.tagName}>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ifaceId = root.getAttribute('id') ?? '';
|
||||||
const name = root.getAttribute('name') ?? 'Untitled';
|
const name = root.getAttribute('name') ?? 'Untitled';
|
||||||
const version = parseInt(root.getAttribute('version') ?? '1', 10);
|
const version = parseInt(root.getAttribute('version') ?? '1', 10);
|
||||||
|
const w = parseInt(root.getAttribute('width') ?? '1200', 10);
|
||||||
|
const h = parseInt(root.getAttribute('height') ?? '800', 10);
|
||||||
|
|
||||||
const widgets: Widget[] = [];
|
const widgets: Widget[] = [];
|
||||||
|
|
||||||
@@ -62,5 +100,5 @@ export function parseInterface(xml: string): Interface {
|
|||||||
widgets.push({ id, type, x, y, w, h, signals, options });
|
widgets.push({ id, type, x, y, w, h, signals, options });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, version, widgets };
|
return { id: ifaceId, name, version, w, h, widgets };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { mount } from 'svelte'
|
|
||||||
import './app.css'
|
|
||||||
import App from './App.svelte'
|
|
||||||
|
|
||||||
const app = mount(App, {
|
|
||||||
target: document.getElementById('app')!,
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { h, render, Fragment } from 'preact';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app')!);
|
||||||
Vendored
+97
@@ -0,0 +1,97 @@
|
|||||||
|
// Minimal type declarations for vendored Preact — no npm required.
|
||||||
|
|
||||||
|
// Global JSX namespace — required for "jsx": "react" + jsxFactory: "h"
|
||||||
|
declare namespace JSX {
|
||||||
|
type Element = import('preact').VNode;
|
||||||
|
interface ElementClass { render(): import('preact').ComponentChild; }
|
||||||
|
interface ElementAttributesProperty { props: {}; }
|
||||||
|
interface ElementChildrenAttribute { children: {}; }
|
||||||
|
type IntrinsicElements = {
|
||||||
|
[K in keyof HTMLElementTagNameMap]: any;
|
||||||
|
} & {
|
||||||
|
[K in keyof SVGElementTagNameMap]: any;
|
||||||
|
} & { [key: string]: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'preact' {
|
||||||
|
export type Key = string | number;
|
||||||
|
export type Ref<T> = { current: T | null };
|
||||||
|
export type VNode<P = {}> = { type: any; props: P; key: Key | null };
|
||||||
|
export type ComponentType<P = {}> = (props: P) => VNode | null;
|
||||||
|
export type ComponentChild = VNode | string | number | boolean | null | undefined;
|
||||||
|
export type ComponentChildren = ComponentChild | ComponentChild[];
|
||||||
|
|
||||||
|
export interface Attributes {
|
||||||
|
key?: Key;
|
||||||
|
ref?: Ref<any>;
|
||||||
|
children?: ComponentChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function h(type: any, props: any, ...children: any[]): VNode;
|
||||||
|
export const Fragment: unique symbol;
|
||||||
|
export function render(vnode: ComponentChild, parent: Element | ShadowRoot): void;
|
||||||
|
export function cloneElement(vnode: VNode, props?: any, ...children: any[]): VNode;
|
||||||
|
|
||||||
|
namespace JSX {
|
||||||
|
type Element = VNode;
|
||||||
|
interface ElementClass { render(): ComponentChild; }
|
||||||
|
interface ElementAttributesProperty { props: {}; }
|
||||||
|
interface ElementChildrenAttribute { children: {}; }
|
||||||
|
// Allow any HTML/SVG attributes on intrinsic elements
|
||||||
|
type IntrinsicElements = {
|
||||||
|
[K in keyof HTMLElementTagNameMap]: any;
|
||||||
|
} & {
|
||||||
|
[K in keyof SVGElementTagNameMap]: any;
|
||||||
|
} & { [key: string]: any };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'preact/hooks' {
|
||||||
|
import type { Ref, VNode } from 'preact';
|
||||||
|
|
||||||
|
export type StateUpdater<S> = S | ((prevState: S) => S);
|
||||||
|
export function useState<S>(initialState: S | (() => S)): [S, (update: StateUpdater<S>) => void];
|
||||||
|
export function useReducer<S, A>(reducer: (state: S, action: A) => S, initialState: S): [S, (action: A) => void];
|
||||||
|
export function useEffect(effect: () => void | (() => void), inputs?: readonly any[]): void;
|
||||||
|
export function useLayoutEffect(effect: () => void | (() => void), inputs?: readonly any[]): void;
|
||||||
|
export function useRef<T>(initialValue: T): { current: T };
|
||||||
|
export function useRef<T>(initialValue: T | null): Ref<T>;
|
||||||
|
export function useMemo<T>(factory: () => T, inputs: readonly any[]): T;
|
||||||
|
export function useCallback<T extends (...args: any[]) => any>(callback: T, inputs: readonly any[]): T;
|
||||||
|
export function useContext<T>(context: import('preact').Context<T>): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'uplot' {
|
||||||
|
namespace uPlot {
|
||||||
|
type AlignedData = (number | null | undefined)[][];
|
||||||
|
interface Series { label?: string; stroke?: string; width?: number; [k: string]: any; }
|
||||||
|
interface Scale { min?: number; max?: number; time?: boolean; [k: string]: any; }
|
||||||
|
interface Axis { stroke?: string; grid?: any; ticks?: any; [k: string]: any; }
|
||||||
|
interface Options {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
series: Series[];
|
||||||
|
legend?: { show?: boolean };
|
||||||
|
axes?: Axis[];
|
||||||
|
scales?: { [key: string]: Scale };
|
||||||
|
[k: string]: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class uPlot {
|
||||||
|
constructor(opts: uPlot.Options, data: uPlot.AlignedData, target: HTMLElement);
|
||||||
|
setData(data: uPlot.AlignedData): void;
|
||||||
|
setSize(size: { width: number; height: number }): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export default uPlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'echarts' {
|
||||||
|
interface EChartsType {
|
||||||
|
setOption(option: any, opts?: any): void;
|
||||||
|
resize(): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
export function init(el: HTMLElement, theme?: string | null): EChartsType;
|
||||||
|
export type { EChartsType };
|
||||||
|
}
|
||||||
+1312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"jsxFragmentFactory": "Fragment",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts", "./**/*.tsx", "./preact.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
function qualityColor(q: string): string {
|
||||||
|
switch (q) {
|
||||||
|
case 'good': return '#4ade80';
|
||||||
|
case 'uncertain': return '#fbbf24';
|
||||||
|
case 'bad': return '#f87171';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function BarH({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||||
|
return () => { unsubV(); unsubM(); };
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||||
|
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||||
|
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||||
|
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||||
|
const quality = sv.quality;
|
||||||
|
|
||||||
|
const rawV = sv.value;
|
||||||
|
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||||
|
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||||
|
|
||||||
|
function displayValue(): string {
|
||||||
|
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||||
|
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillPercent(): number {
|
||||||
|
if (rawValue === null || isNaN(rawValue)) return 0;
|
||||||
|
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
|
||||||
|
return Math.max(0, Math.min(100, frac * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="barh"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||||
|
{label && <div class="bar-label">{label}</div>}
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill" style={`width:${fillPercent()}%;`} />
|
||||||
|
</div>
|
||||||
|
<div class="bar-value">
|
||||||
|
<span class="value">{displayValue()}</span>
|
||||||
|
{unit && <span class="unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
function qualityColor(q: string): string {
|
||||||
|
switch (q) {
|
||||||
|
case 'good': return '#4ade80';
|
||||||
|
case 'uncertain': return '#fbbf24';
|
||||||
|
case 'bad': return '#f87171';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function BarV({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||||
|
return () => { unsubV(); unsubM(); };
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||||
|
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||||
|
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||||
|
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||||
|
const quality = sv.quality;
|
||||||
|
|
||||||
|
const rawV = sv.value;
|
||||||
|
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||||
|
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||||
|
|
||||||
|
function displayValue(): string {
|
||||||
|
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||||
|
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillPercent(): number {
|
||||||
|
if (rawValue === null || isNaN(rawValue)) return 0;
|
||||||
|
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
|
||||||
|
return Math.max(0, Math.min(100, frac * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="barv"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||||
|
{label && <div class="bar-label">{label}</div>}
|
||||||
|
<div class="bar-value">
|
||||||
|
<span class="value">{displayValue()}</span>
|
||||||
|
{unit && <span class="unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill" style={`height:${fillPercent()}%;`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { wsClient } from '../lib/ws';
|
||||||
|
import type { Widget } from '../lib/types';
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function Button({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const label = widget.options['label'] ?? 'Button';
|
||||||
|
const valueOpt = widget.options['value'] ?? '1';
|
||||||
|
const confirm = widget.options['confirm'] === 'true';
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const parsed = parseFloat(valueOpt);
|
||||||
|
const val = isNaN(parsed) ? valueOpt : parsed;
|
||||||
|
const doWrite = () => wsClient.write(sigRef, val);
|
||||||
|
if (confirm) {
|
||||||
|
if (window.confirm(`Send ${label}?`)) doWrite();
|
||||||
|
} else {
|
||||||
|
doWrite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="button-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<button class="btn" onClick={handleClick}>{label}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
function qualityColor(q: string): string {
|
||||||
|
switch (q) {
|
||||||
|
case 'good': return '#4ade80';
|
||||||
|
case 'uncertain': return '#fbbf24';
|
||||||
|
case 'bad': return '#f87171';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arc: -135deg to +135deg (270deg total)
|
||||||
|
const START_DEG = -135;
|
||||||
|
const END_DEG = 135;
|
||||||
|
const TOTAL_DEG = 270;
|
||||||
|
const cx = 50, cy = 54, r = 38;
|
||||||
|
|
||||||
|
function degToRad(deg: number) { return (deg * Math.PI) / 180; }
|
||||||
|
function polarToXY(deg: number, radius: number) {
|
||||||
|
const rad = degToRad(deg);
|
||||||
|
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||||||
|
}
|
||||||
|
function arcPath(startDeg: number, endDeg: number, radius: number): string {
|
||||||
|
const s = polarToXY(startDeg, radius);
|
||||||
|
const e = polarToXY(endDeg, radius);
|
||||||
|
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||||
|
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 {
|
||||||
|
const clamped = Math.max(minVal, Math.min(maxVal, v));
|
||||||
|
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
|
||||||
|
return START_DEG + frac * TOTAL_DEG;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function Gauge({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||||
|
return () => { unsubV(); unsubM(); };
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||||
|
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||||
|
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||||
|
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||||
|
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
|
||||||
|
const thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null;
|
||||||
|
|
||||||
|
const quality = sv.quality;
|
||||||
|
const rawV = sv.value;
|
||||||
|
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||||
|
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||||
|
|
||||||
|
function displayValue(): string {
|
||||||
|
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||||
|
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillColor(): string {
|
||||||
|
if (rawValue === null) return '#4a9eff';
|
||||||
|
if (thresholdHigh !== null && rawValue >= thresholdHigh) return '#ef4444';
|
||||||
|
if (thresholdLow !== null && rawValue <= thresholdLow) return '#f59e0b';
|
||||||
|
return '#4a9eff';
|
||||||
|
}
|
||||||
|
|
||||||
|
const needleDeg = rawValue === null ? START_DEG : valueToDeg(rawValue, minVal, maxVal);
|
||||||
|
const bgPath = arcPath(START_DEG, END_DEG, r);
|
||||||
|
const fillPath = needleDeg > START_DEG ? arcPath(START_DEG, needleDeg, r) : '';
|
||||||
|
const needlePos = polarToXY(needleDeg, r - 4);
|
||||||
|
const fc = fillColor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="gauge"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||||
|
<svg viewBox="0 0 100 80" class="gauge-svg">
|
||||||
|
<path d={bgPath} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
|
||||||
|
{fillPath && (
|
||||||
|
<path d={fillPath} fill="none" stroke={fc} stroke-width="6" stroke-linecap="round" />
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{unit && <text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>}
|
||||||
|
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
|
||||||
|
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
||||||
|
</svg>
|
||||||
|
{label && <div class="gauge-label">{label}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import type { Widget } from '../lib/types';
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function ImageWidget({ widget, onContextMenu }: Props) {
|
||||||
|
const url = widget.options['url'] ?? '';
|
||||||
|
const fit = widget.options['fit'] ?? 'contain'; // contain | cover | fill
|
||||||
|
const alt = widget.options['alt'] ?? '';
|
||||||
|
const border = widget.options['border'] !== 'false';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="image-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;${border ? '' : 'border:none;'}`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{url
|
||||||
|
? <img src={url} alt={alt} style={`object-fit:${fit};width:100%;height:100%;display:block;`} />
|
||||||
|
: <span class="image-placeholder">No URL</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore } from '../lib/stores';
|
||||||
|
import type { Widget, SignalValue } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
function evaluateCondition(expr: string, v: any): boolean {
|
||||||
|
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) return false;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
return new Function('value', 'return ' + expr)(v);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function Led({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsub = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
return unsub;
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||||
|
const condition = widget.options['condition'] ?? 'value > 0';
|
||||||
|
const colorTrue = widget.options['colorTrue'] ?? '#22c55e';
|
||||||
|
const colorFalse = widget.options['colorFalse'] ?? '#ef4444';
|
||||||
|
|
||||||
|
const quality = sv.quality;
|
||||||
|
const isUncertain = quality === 'uncertain' || quality === 'unknown';
|
||||||
|
const ledOn = sv.value !== null && sv.value !== undefined ? evaluateCondition(condition, sv.value) : false;
|
||||||
|
const ledColor = ledOn ? colorTrue : colorFalse;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="led-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`led-circle${isUncertain ? ' blink' : ''}`}
|
||||||
|
style={`background:${ledColor};box-shadow:0 0 8px ${ledColor}88;`}
|
||||||
|
/>
|
||||||
|
{label && <div class="led-label">{label}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import type { Widget } from '../lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: Widget;
|
||||||
|
onContextMenu?: (e: MouseEvent) => void;
|
||||||
|
onNavigate?: (interfaceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LinkWidget({ widget, onContextMenu, onNavigate }: Props) {
|
||||||
|
const target = widget.options['target'] ?? '';
|
||||||
|
const label = widget.options['label'] ?? target ?? 'Open';
|
||||||
|
const icon = widget.options['icon'] ?? '↗';
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (target && onNavigate) onNavigate(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="link-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<button class="link-btn" onClick={handleClick} title={`Navigate to: ${target}`}>
|
||||||
|
<span class="link-icon">{icon}</span>
|
||||||
|
<span class="link-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore } from '../lib/stores';
|
||||||
|
import type { Widget, SignalValue } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
function getBit(val: number, bit: number): boolean {
|
||||||
|
return (val & (1 << bit)) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function MultiLed({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsub = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
return unsub;
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const bits = parseInt(widget.options['bits'] ?? '8', 10);
|
||||||
|
const labelsRaw = widget.options['labels'] ?? '';
|
||||||
|
const colorsTrueRaw = widget.options['colorsTrue'] ?? '';
|
||||||
|
const colorsFalseRaw = widget.options['colorsFalse'] ?? '';
|
||||||
|
|
||||||
|
const labelArr = labelsRaw ? labelsRaw.split(',') : [];
|
||||||
|
const colorsTrueArr = colorsTrueRaw ? colorsTrueRaw.split(',') : [];
|
||||||
|
const colorsFalseArr = colorsFalseRaw ? colorsFalseRaw.split(',') : [];
|
||||||
|
|
||||||
|
const quality = sv.quality;
|
||||||
|
const isUncertain = quality === 'uncertain' || quality === 'unknown';
|
||||||
|
|
||||||
|
const rawV = sv.value;
|
||||||
|
const intValue = rawV === null || rawV === undefined ? 0
|
||||||
|
: typeof rawV === 'number' ? Math.floor(rawV) : parseInt(String(rawV), 10);
|
||||||
|
|
||||||
|
const bitStates = Array.from({ length: bits }, (_, i) => getBit(intValue, i));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="multiled-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{bitStates.map((on, i) => {
|
||||||
|
const trueColor = colorsTrueArr[i] ?? '#22c55e';
|
||||||
|
const falseColor = colorsFalseArr[i] ?? '#ef4444';
|
||||||
|
const color = on ? trueColor : falseColor;
|
||||||
|
const bitLabel = labelArr[i] ?? String(i);
|
||||||
|
return (
|
||||||
|
<div key={i} class="bit-item">
|
||||||
|
<div
|
||||||
|
class={`led-circle${isUncertain ? ' blink' : ''}`}
|
||||||
|
style={`background:${color};box-shadow:0 0 6px ${color}88;`}
|
||||||
|
/>
|
||||||
|
<div class="bit-label">{bitLabel}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
|
import uPlot from 'uplot';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import { getSignalStore } from '../lib/stores';
|
||||||
|
import type { Widget, SignalRef } from '../lib/types';
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
interface SeriesBuffer {
|
||||||
|
timestamps: number[];
|
||||||
|
values: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RING_MAX = 4000;
|
||||||
|
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||||
|
|
||||||
|
const ECHARTS_DARK = {
|
||||||
|
backgroundColor: '#1a1f2e',
|
||||||
|
textStyle: { color: '#94a3b8' },
|
||||||
|
axisLineStyle: { color: '#475569' },
|
||||||
|
axisLabelStyle: { color: '#64748b' },
|
||||||
|
splitLineStyle: { color: '#2d3748' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
|
||||||
|
buf.timestamps.push(ts);
|
||||||
|
buf.values.push(val);
|
||||||
|
if (buf.timestamps.length > RING_MAX) {
|
||||||
|
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
|
||||||
|
buf.values.splice(0, buf.values.length - RING_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowedSlice(buf: SeriesBuffer, windowSec: number) {
|
||||||
|
const cutoff = Date.now() / 1000 - windowSec;
|
||||||
|
const start = buf.timestamps.findIndex(t => t >= cutoff);
|
||||||
|
if (start === -1) return { ts: [] as number[], vals: [] as number[] };
|
||||||
|
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
|
||||||
|
const BUCKETS = 20;
|
||||||
|
let lo = Infinity, hi = -Infinity;
|
||||||
|
for (const vals of allVals) for (const v of vals) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||||
|
if (!isFinite(lo) || lo === hi) return { labels: [], counts: allVals.map(() => []) };
|
||||||
|
const size = (hi - lo) / BUCKETS;
|
||||||
|
const counts = allVals.map(vals => {
|
||||||
|
const bins = new Array<number>(BUCKETS).fill(0);
|
||||||
|
for (const v of vals) bins[Math.min(BUCKETS - 1, Math.floor((v - lo) / size))]++;
|
||||||
|
return bins;
|
||||||
|
});
|
||||||
|
const labels = Array.from({ length: BUCKETS }, (_, i) => (lo + i * size).toPrecision(3));
|
||||||
|
return { labels, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlotWidget({ widget, onContextMenu }: Props) {
|
||||||
|
const outerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartRef.current) return;
|
||||||
|
|
||||||
|
const signals = widget.signals;
|
||||||
|
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||||
|
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
|
||||||
|
const yMin = widget.options['yMin'];
|
||||||
|
const yMax = widget.options['yMax'];
|
||||||
|
const legendPos = widget.options['legend'] ?? 'bottom';
|
||||||
|
const showLegend = legendPos !== 'none';
|
||||||
|
|
||||||
|
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
||||||
|
const histValues: number[][] = signals.map(() => []);
|
||||||
|
const unsubs: (() => void)[] = [];
|
||||||
|
|
||||||
|
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
||||||
|
let uplot: uPlot | null = null;
|
||||||
|
|
||||||
|
function uplotData(): uPlot.AlignedData {
|
||||||
|
if (signals.length === 0) return [[]];
|
||||||
|
const allTs = new Set<number>();
|
||||||
|
buffers.forEach(b => windowedSlice(b, timeWindow).ts.forEach(t => allTs.add(t)));
|
||||||
|
const tsSorted = Array.from(allTs).sort((a, b) => a - b);
|
||||||
|
if (tsSorted.length === 0) {
|
||||||
|
// Return proper empty structure: [timestamps, ...series]
|
||||||
|
return [[], ...signals.map(() => [])] as uPlot.AlignedData;
|
||||||
|
}
|
||||||
|
const tsMap = buffers.map(b => {
|
||||||
|
const m = new Map<number, number>();
|
||||||
|
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||||
|
ts.forEach((t, i) => m.set(t, vals[i]));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
tsSorted,
|
||||||
|
...tsMap.map(m => tsSorted.map(t => m.get(t) ?? null)),
|
||||||
|
] as uPlot.AlignedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ECharts ────────────────────────────────────────────────────────────
|
||||||
|
let echart: echarts.EChartsType | null = null;
|
||||||
|
|
||||||
|
function echartsOption(): any {
|
||||||
|
const axisLine = { lineStyle: { color: '#475569' } };
|
||||||
|
const axisLabel = { color: '#64748b' };
|
||||||
|
const splitLine = { lineStyle: { color: '#2d3748' } };
|
||||||
|
const legendOpt = showLegend
|
||||||
|
? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
switch (plotType) {
|
||||||
|
case 'histogram': {
|
||||||
|
const { labels, counts } = buildHistogram(histValues);
|
||||||
|
return {
|
||||||
|
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||||
|
legend: legendOpt,
|
||||||
|
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine },
|
||||||
|
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||||
|
series: counts.map((d, i) => ({
|
||||||
|
name: signals[i]?.name ?? `S${i}`,
|
||||||
|
type: 'bar',
|
||||||
|
data: d,
|
||||||
|
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'bar': {
|
||||||
|
return {
|
||||||
|
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||||
|
legend: legendOpt,
|
||||||
|
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
|
||||||
|
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: buffers.map((b, i) => ({
|
||||||
|
value: b.values.length ? b.values[b.values.length - 1] : 0,
|
||||||
|
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'fft': {
|
||||||
|
return {
|
||||||
|
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||||
|
legend: legendOpt,
|
||||||
|
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel },
|
||||||
|
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||||
|
series: buffers.map((b, i) => ({
|
||||||
|
name: signals[i]?.name ?? `S${i}`,
|
||||||
|
type: 'line',
|
||||||
|
data: b.values.length ? b.values[b.values.length - 1] : [],
|
||||||
|
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||||
|
showSymbol: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'logic': {
|
||||||
|
return {
|
||||||
|
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||||
|
legend: legendOpt,
|
||||||
|
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel },
|
||||||
|
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine },
|
||||||
|
series: buffers.map((b, i) => {
|
||||||
|
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||||
|
return {
|
||||||
|
name: signals[i]?.name ?? `S${i}`,
|
||||||
|
type: 'line', step: 'end',
|
||||||
|
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
||||||
|
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||||
|
showSymbol: false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'waterfall': {
|
||||||
|
const ROWS = 32;
|
||||||
|
const b = buffers[0];
|
||||||
|
const step = Math.max(1, Math.floor(b.values.length / ROWS));
|
||||||
|
const flatData: [number, number, number][] = [];
|
||||||
|
for (let ri = 0; ri < ROWS; ri++) {
|
||||||
|
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step;
|
||||||
|
if (idx < 0) continue;
|
||||||
|
const row = b.values[idx];
|
||||||
|
const arr = Array.isArray(row) ? row : [row];
|
||||||
|
arr.forEach((val, ci) => flatData.push([ci, ri, val]));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||||
|
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
||||||
|
xAxis: { type: 'value', axisLabel },
|
||||||
|
yAxis: { type: 'value', axisLabel },
|
||||||
|
series: [{ type: 'heatmap', data: flatData }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { backgroundColor: ECHARTS_DARK.backgroundColor };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init chart ─────────────────────────────────────────────────────────
|
||||||
|
const el = chartRef.current;
|
||||||
|
const w = el.clientWidth || widget.w;
|
||||||
|
const h = el.clientHeight || widget.h;
|
||||||
|
|
||||||
|
if (plotType === 'timeseries') {
|
||||||
|
const seriesConf: uPlot.Series[] = [
|
||||||
|
{},
|
||||||
|
...signals.map((s, i) => ({
|
||||||
|
label: s.name,
|
||||||
|
stroke: s.color ?? COLORS[i % COLORS.length],
|
||||||
|
width: 1.5,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const scaleY: uPlot.Scale = {};
|
||||||
|
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
|
||||||
|
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
|
||||||
|
|
||||||
|
uplot = new uPlot(
|
||||||
|
{
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
series: seriesConf,
|
||||||
|
legend: { show: showLegend },
|
||||||
|
axes: [
|
||||||
|
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||||
|
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||||
|
],
|
||||||
|
scales: { x: { time: true }, y: scaleY },
|
||||||
|
},
|
||||||
|
uplotData(),
|
||||||
|
el,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
echart = echarts.init(el);
|
||||||
|
echart.setOption(echartsOption());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subscribe to signals ───────────────────────────────────────────────
|
||||||
|
signals.forEach((sig: SignalRef & { color?: string }, i) => {
|
||||||
|
const unsub = getSignalStore(sig).subscribe(sv => {
|
||||||
|
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||||
|
const ts = new Date(sv.ts).getTime() / 1000;
|
||||||
|
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
|
||||||
|
if (isNaN(v)) return;
|
||||||
|
|
||||||
|
pushSample(buffers[i], ts, v);
|
||||||
|
if (plotType === 'histogram') histValues[i].push(v);
|
||||||
|
|
||||||
|
if (uplot) uplot.setData(uplotData());
|
||||||
|
else if (echart) echart.setOption(echartsOption(), { notMerge: true });
|
||||||
|
});
|
||||||
|
unsubs.push(unsub);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Resize ─────────────────────────────────────────────────────────────
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (!chartRef.current) return;
|
||||||
|
const nw = chartRef.current.clientWidth;
|
||||||
|
const nh = chartRef.current.clientHeight;
|
||||||
|
if (nw > 0 && nh > 0) {
|
||||||
|
if (uplot) uplot.setSize({ width: nw, height: nh });
|
||||||
|
if (echart) echart.resize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (outerRef.current) ro.observe(outerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
unsubs.forEach(u => u());
|
||||||
|
if (uplot) uplot.destroy();
|
||||||
|
if (echart) echart.dispose();
|
||||||
|
};
|
||||||
|
}, [widget.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="plot-widget"
|
||||||
|
ref={outerRef}
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { wsClient } from '../lib/ws';
|
||||||
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
function qualityColor(q: string): string {
|
||||||
|
switch (q) {
|
||||||
|
case 'good': return '#4ade80';
|
||||||
|
case 'uncertain': return '#fbbf24';
|
||||||
|
case 'bad': return '#f87171';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function SetValue({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||||
|
return () => { unsubV(); unsubM(); };
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||||
|
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||||
|
const confirm = widget.options['confirm'] === 'true';
|
||||||
|
const isNumeric = meta ? meta.type !== 'TypeString' : true;
|
||||||
|
const quality = sv.quality;
|
||||||
|
|
||||||
|
function displayValue(): string {
|
||||||
|
const v = sv.value;
|
||||||
|
if (v === null || v === undefined) return '---';
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSet() {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const raw = inputValue.trim();
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
const doWrite = () => {
|
||||||
|
const val = isNumeric ? parseFloat(raw) : raw;
|
||||||
|
wsClient.write(sigRef, val);
|
||||||
|
setInputValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (confirm) {
|
||||||
|
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
|
||||||
|
} else {
|
||||||
|
doWrite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="setvalue"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||||
|
<span class="sv-label">{label}:</span>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import type { Widget } from '../lib/types';
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function TextLabel({ widget, onContextMenu }: Props) {
|
||||||
|
const text = widget.options['text'] ?? '';
|
||||||
|
const fontSize = widget.options['fontSize'] ?? '1rem';
|
||||||
|
const color = widget.options['color'] ?? '#e2e8f0';
|
||||||
|
const align = widget.options['align'] ?? 'left';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="textlabel"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;font-size:${fontSize};color:${color};text-align:${align};`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
function qualityColor(q: string): string {
|
||||||
|
switch (q) {
|
||||||
|
case 'good': return '#4ade80';
|
||||||
|
case 'uncertain': return '#fbbf24';
|
||||||
|
case 'bad': return '#f87171';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
export default function TextView({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||||
|
return () => { unsubV(); unsubM(); };
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||||
|
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||||
|
const quality = sv.quality;
|
||||||
|
|
||||||
|
function displayValue(): string {
|
||||||
|
if (!sv || sv.value === null || sv.value === undefined) return '---';
|
||||||
|
const v = sv.value;
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="textview"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||||
|
<span class="label">{label}:</span>
|
||||||
|
<span class="value">{displayValue()}</span>
|
||||||
|
{unit && <span class="unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
|
||||||
export default {}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "es2023",
|
|
||||||
"module": "esnext",
|
|
||||||
"types": ["svelte", "vite/client"],
|
|
||||||
"noEmit": true,
|
|
||||||
/**
|
|
||||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
|
||||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
|
||||||
* Note that setting allowJs false does not prevent the use
|
|
||||||
* of JS in `.svelte` files.
|
|
||||||
*/
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"moduleDetection": "force"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
|
||||||
}
|
|
||||||
+2
-5
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"extends": "./src/tsconfig.json",
|
||||||
"references": [
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/preact.d.ts"]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "es2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "esnext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
Vendored
+45
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Ref, Context, ComponentChild } from './preact';
|
||||||
|
|
||||||
|
export type StateUpdater<S> = S | ((prevState: S) => S);
|
||||||
|
export function useState<S>(initialState: S | (() => S)): [S, (update: StateUpdater<S>) => void];
|
||||||
|
export function useReducer<S, A>(reducer: (state: S, action: A) => S, initialState: S): [S, (action: A) => void];
|
||||||
|
export function useEffect(effect: () => void | (() => void), inputs?: readonly unknown[]): void;
|
||||||
|
export function useLayoutEffect(effect: () => void | (() => void), inputs?: readonly unknown[]): void;
|
||||||
|
export function useRef<T>(initialValue: T): { current: T };
|
||||||
|
export function useRef<T>(initialValue: T | null): Ref<T>;
|
||||||
|
export function useRef<T = undefined>(): Ref<T | undefined>;
|
||||||
|
export function useMemo<T>(factory: () => T, inputs: readonly unknown[]): T;
|
||||||
|
export function useCallback<T extends (...args: any[]) => any>(callback: T, inputs: readonly unknown[]): T;
|
||||||
|
export function useContext<T>(context: Context<T>): T;
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
import{options as n}from"preact";var t,r,u,i,o=0,f=[],c=n,e=c.__b,a=c.__r,v=c.diffed,l=c.__c,m=c.unmount,s=c.__;function d(n,t){c.__h&&c.__h(r,n,o||t),o=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function h(n){return o=1,p(D,n)}function p(n,u,i){var o=d(t++,2);if(o.t=n,!o.__c&&(o.__=[i?i(u):D(void 0,u),function(n){var t=o.__N?o.__N[0]:o.__[0],r=o.t(t,n);t!==r&&(o.__N=[r,o.__[1]],o.__c.setState({}))}],o.__c=r,!r.u)){var f=function(n,t,r){if(!o.__c.__H)return!0;var u=o.__c.__H.__.filter(function(n){return!!n.__c});if(u.every(function(n){return!n.__N}))return!c||c.call(this,n,t,r);var i=!1;return u.forEach(function(n){if(n.__N){var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=!0)}}),!(!i&&o.__c.props===n)&&(!c||c.call(this,n,t,r))};r.u=!0;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,r){if(this.__e){var u=c;c=void 0,f(n,t,r),c=u}e&&e.call(this,n,t,r)},r.shouldComponentUpdate=f}return o.__N||o.__}function y(n,u){var i=d(t++,3);!c.__s&&C(i.__H,u)&&(i.__=n,i.i=u,r.__H.__h.push(i))}function _(n,u){var i=d(t++,4);!c.__s&&C(i.__H,u)&&(i.__=n,i.i=u,r.__h.push(i))}function A(n){return o=5,T(function(){return{current:n}},[])}function F(n,t,r){o=6,_(function(){return"function"==typeof n?(n(t()),function(){return n(null)}):n?(n.current=t(),function(){return n.current=null}):void 0},null==r?r:r.concat(n))}function T(n,r){var u=d(t++,7);return C(u.__H,r)&&(u.__=n(),u.__H=r,u.__h=n),u.__}function q(n,t){return o=8,T(function(){return n},t)}function x(n){var u=r.context[n.__c],i=d(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function P(n,t){c.useDebugValue&&c.useDebugValue(t?t(n):n)}function b(n){var u=d(t++,10),i=h();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function g(){var n=d(t++,11);if(!n.__){for(var u=r.__v;null!==u&&!u.__m&&null!==u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function j(){for(var n;n=f.shift();)if(n.__P&&n.__H)try{n.__H.__h.forEach(z),n.__H.__h.forEach(B),n.__H.__h=[]}catch(t){n.__H.__h=[],c.__e(t,n.__v)}}c.__b=function(n){r=null,e&&e(n)},c.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),s&&s(n,t)},c.__r=function(n){a&&a(n),t=0;var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.forEach(function(n){n.__N&&(n.__=n.__N),n.i=n.__N=void 0})):(i.__h.forEach(z),i.__h.forEach(B),i.__h=[],t=0)),u=r},c.diffed=function(n){v&&v(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==f.push(t)&&i===c.requestAnimationFrame||((i=c.requestAnimationFrame)||w)(j)),t.__H.__.forEach(function(n){n.i&&(n.__H=n.i),n.i=void 0})),u=r=null},c.__c=function(n,t){t.some(function(n){try{n.__h.forEach(z),n.__h=n.__h.filter(function(n){return!n.__||B(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],c.__e(r,n.__v)}}),l&&l(n,t)},c.unmount=function(n){m&&m(n);var t,r=n.__c;r&&r.__H&&(r.__H.__.forEach(function(n){try{z(n)}catch(n){t=n}}),r.__H=void 0,t&&c.__e(t,r.__v))};var k="function"==typeof requestAnimationFrame;function w(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,100);k&&(t=requestAnimationFrame(r))}function z(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function B(n){var t=r;n.__c=n.__(),r=t}function C(n,t){return!n||n.length!==t.length||t.some(function(t,r){return t!==n[r]})}function D(n,t){return"function"==typeof t?t(n):t}export{q as useCallback,x as useContext,P as useDebugValue,y as useEffect,b as useErrorBoundary,g as useId,F as useImperativeHandle,_ as useLayoutEffect,T as useMemo,p as useReducer,A as useRef,h as useState};
|
||||||
|
//# sourceMappingURL=hooks.module.js.map
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
export type Key = string | number;
|
||||||
|
export interface Ref<T> { current: T | null; }
|
||||||
|
export type ComponentChild = VNode | string | number | bigint | boolean | null | undefined;
|
||||||
|
export type ComponentChildren = ComponentChild | ComponentChild[];
|
||||||
|
export interface Attributes { key?: Key; ref?: Ref<any>; children?: ComponentChildren; }
|
||||||
|
export type VNode<P = {}> = { type: any; props: P & { children?: ComponentChildren }; key: Key | null; };
|
||||||
|
export type ComponentType<P = {}> = (props: P & Attributes) => VNode | null;
|
||||||
|
export type Context<T> = { Provider: ComponentType<{ value: T; children?: ComponentChildren }>; Consumer: ComponentType<{ children: (value: T) => ComponentChild }> };
|
||||||
|
|
||||||
|
export function h(type: any, props: any, ...children: any[]): VNode;
|
||||||
|
export const Fragment: unique symbol;
|
||||||
|
export function render(vnode: ComponentChild, parent: Element | ShadowRoot): void;
|
||||||
|
export function cloneElement(vnode: VNode, props?: any, ...children: any[]): VNode;
|
||||||
|
export function createContext<T>(defaultValue: T): Context<T>;
|
||||||
|
|
||||||
|
export namespace JSX {
|
||||||
|
type Element = VNode;
|
||||||
|
interface ElementClass { render(): ComponentChild; }
|
||||||
|
interface ElementAttributesProperty { props: {}; }
|
||||||
|
interface ElementChildrenAttribute { children: {}; }
|
||||||
|
type IntrinsicElements = { [K in keyof HTMLElementTagNameMap]: any } & { [K in keyof SVGElementTagNameMap]: any } & { [key: string]: any };
|
||||||
|
}
|
||||||
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
|
||||||
Vendored
+6140
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [svelte()],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:8080',
|
|
||||||
'/healthz': 'http://localhost:8080',
|
|
||||||
'/ws': {
|
|
||||||
target: 'ws://localhost:8080',
|
|
||||||
ws: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<interface id="demo" name="Demo Panel" version="1" width="1200" height="800">
|
||||||
|
<!-- Text view widgets -->
|
||||||
|
<widget type="textview" id="tv1" x="20" y="20" w="200" h="60">
|
||||||
|
<signal ds="stub" name="sine_1hz"/>
|
||||||
|
<option key="label" value="Sine 1 Hz"/>
|
||||||
|
</widget>
|
||||||
|
<widget type="textview" id="tv2" x="240" y="20" w="200" h="60">
|
||||||
|
<signal ds="stub" name="ramp_10s"/>
|
||||||
|
<option key="label" value="Ramp 10s"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Gauge widgets -->
|
||||||
|
<widget type="gauge" id="g1" x="20" y="100" w="160" h="160">
|
||||||
|
<signal ds="stub" name="sine_1hz"/>
|
||||||
|
<option key="label" value="Sine 1Hz"/>
|
||||||
|
<option key="min" value="-1"/>
|
||||||
|
<option key="max" value="1"/>
|
||||||
|
</widget>
|
||||||
|
<widget type="gauge" id="g2" x="200" y="100" w="160" h="160">
|
||||||
|
<signal ds="stub" name="ramp_10s"/>
|
||||||
|
<option key="label" value="Ramp"/>
|
||||||
|
<option key="min" value="0"/>
|
||||||
|
<option key="max" value="100"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Bar widgets -->
|
||||||
|
<widget type="barh" id="bh1" x="20" y="280" w="200" h="40">
|
||||||
|
<signal ds="stub" name="ramp_10s"/>
|
||||||
|
<option key="label" value="Ramp H"/>
|
||||||
|
<option key="min" value="0"/>
|
||||||
|
<option key="max" value="100"/>
|
||||||
|
</widget>
|
||||||
|
<widget type="barv" id="bv1" x="240" y="280" w="40" h="160">
|
||||||
|
<signal ds="stub" name="sine_1hz"/>
|
||||||
|
<option key="label" value="Sine V"/>
|
||||||
|
<option key="min" value="-1"/>
|
||||||
|
<option key="max" value="1"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- LED -->
|
||||||
|
<widget type="led" id="led1" x="300" y="280" w="60" h="60">
|
||||||
|
<signal ds="stub" name="toggle_1hz"/>
|
||||||
|
<option key="label" value="Toggle"/>
|
||||||
|
<option key="condition" value="value > 0"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Set value -->
|
||||||
|
<widget type="setvalue" id="sv1" x="20" y="460" w="200" h="60">
|
||||||
|
<signal ds="stub" name="setpoint"/>
|
||||||
|
<option key="label" value="Setpoint"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Button -->
|
||||||
|
<widget type="button" id="btn1" x="240" y="460" w="120" h="60">
|
||||||
|
<signal ds="stub" name="setpoint"/>
|
||||||
|
<option key="label" value="Reset SP"/>
|
||||||
|
<option key="value" value="25"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Plot timeseries -->
|
||||||
|
<widget type="plot" id="plot1" x="380" y="20" w="400" h="200">
|
||||||
|
<signal ds="stub" name="sine_1hz"/>
|
||||||
|
<signal ds="stub" name="sine_01hz"/>
|
||||||
|
<option key="plotType" value="timeseries"/>
|
||||||
|
<option key="timeWindow" value="30"/>
|
||||||
|
<option key="label" value="Sine signals"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Plot histogram -->
|
||||||
|
<widget type="plot" id="plot2" x="380" y="240" w="400" h="200">
|
||||||
|
<signal ds="stub" name="noise"/>
|
||||||
|
<option key="plotType" value="histogram"/>
|
||||||
|
<option key="label" value="Noise histogram"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Text label -->
|
||||||
|
<widget type="textlabel" id="lbl1" x="800" y="20" w="200" h="40">
|
||||||
|
<option key="text" value="System Status"/>
|
||||||
|
</widget>
|
||||||
|
|
||||||
|
<!-- Multi LED -->
|
||||||
|
<widget type="multiled" id="ml1" x="800" y="80" w="200" h="120">
|
||||||
|
<signal ds="stub" name="toggle_1hz"/>
|
||||||
|
<option key="label" value="Bits"/>
|
||||||
|
<option key="bits" value="4"/>
|
||||||
|
</widget>
|
||||||
|
</interface>
|
||||||
Reference in New Issue
Block a user