This commit is contained in:
Martino Ferrari committed 2026-04-25 22:56:09 +02:00
1 parent 8b548ba1c2
commit 986f6cd6d8
85 files changed
+11479 -5050

No files matched your search

+5 -11
View File
@@ -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
+57 -28
View File
@@ -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
+32 -12
View File
@@ -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)
} }
// Register EPICS Channel Access data source (Phase 2). brk.Register(stubDS)
// epics.Available() returns false when built without -tags epics so that
// machines without EPICS Base installed still compile and run correctly. // 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)
}
}
// 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
View File
@@ -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 | — |
+3 -4
View File
@@ -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
)
+4 -2
View File
@@ -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=
+137 -12
View File
@@ -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
log *slog.Logger synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
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"`
}
+108
View File
@@ -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)
}
}
+451
View File
@@ -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)
}
}
+5 -2
View File
@@ -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))
+160
View File
@@ -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
}
+29
View File
@@ -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 }
}
]
+129
View File
@@ -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
}
}
Executable
BIN
View File
Binary file not shown.
-47
View File
@@ -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
View File
@@ -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"
-13
View File
@@ -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>
-2505
View File
File diff suppressed because it is too large. Load diff
-29
View File
@@ -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"
}
}
-66
View File
@@ -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>
+47
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+278
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
}
+199
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

-98
View File
@@ -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>
-10
View File
@@ -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>
-20
View File
@@ -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>
-214
View File
@@ -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>
-175
View File
@@ -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>
+41
View File
@@ -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 -1
View File
@@ -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';
+3
View File
@@ -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[];
} }
-132
View File
@@ -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>
-136
View File
@@ -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>
-66
View File
@@ -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>
-187
View File
@@ -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>
-99
View File
@@ -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>
-108
View File
@@ -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>
-378
View File
@@ -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>
-170
View File
@@ -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>
-99
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/** 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 };
} }
-9
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import { h, render, Fragment } from 'preact';
import App from './App';
import './styles.css';
render(<App />, document.getElementById('app')!);
+97
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -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"]
}
+69
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+286
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
-2
View File
@@ -1,2 +0,0 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}
-20
View File
@@ -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
View File
@@ -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" }
]
} }
-24
View File
@@ -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"]
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
var t=function(e,n){return t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},t(e,n)};function e(e,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=e}t(e,n),e.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var n=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},i=new function(){this.browser=new n,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(i.wxa=!0,i.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?i.worker=!0:"undefined"==typeof navigator||0===navigator.userAgent.indexOf("Node.js")?(i.node=!0,i.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11),e.domSupported="undefined"!=typeof document;var s=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in s||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}(navigator.userAgent,i);var r="sans-serif",o="12px "+r;var a,s,l=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n<t.length;n++){var i=String.fromCharCode(n+32),r=(t.charCodeAt(n)-20)/100;e[i]=r}return e}("007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N"),u={createCanvas:function(){return"undefined"!=typeof document&&document.createElement("canvas")},measureText:function(t,e){if(!a){var n=u.createCanvas();a=n&&n.getContext("2d")}if(a)return s!==e&&(s=a.font=e||o),a.measureText(t);t=t||"";var i=/((?:\d+)?\.?\d*)px/.exec(e=e||o),r=i&&+i[1]||12,h=0;if(e.indexOf("mono")>=0)h=r*t.length;else for(var c=0;c<t.length;c++){var p=l[t[c]];h+=null==p?r:p*r}return{width:h}},loadImage:function(t,e,n){var i=new Image;return i.onload=e,i.onerror=n,i.src=t,i}};function h(t){for(var e in u)t[e]&&(u[e]=t[e])}var c=z(["Function","RegExp","Date","Error","CanvasGradient","CanvasPattern","Image","Canvas"],(function(t,e){return t["[object "+e+"]"]=!0,t}),{}),p=z(["Int8","Uint8","Uint8Clamped","Int16","Uint16","Int32","Uint32","Float32","Float64"],(function(t,e){return t["[object "+e+"Array]"]=!0,t}),{}),d=Object.prototype.toString,f=Array.prototype,g=f.forEach,y=f.filter,v=f.slice,m=f.map,x=function(){}.constructor,_=x?x.prototype:null,b="__proto__",w=2311;function S(){return w++}function M(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];"undefined"!=typeof console&&console.error.apply(console,t)}function I(t){if(null==t||"object"!=typeof t)return t;var e=t,n=d.call(t);if("[object Array]"===n){if(!ct(t)){e=[];for(var i=0,r=t.length;i<r;i++)e[i]=I(t[i])}}else if(p[n]){if(!ct(t)){var o=t.constructor;if(o.from)e=o.from(t);else{e=new o(t.length);for(i=0,r=t.length;i<r;i++)e[i]=t[i]}}}else if(!c[n]&&!ct(t)&&!$(t))for(var a in e={},t)t.hasOwnProperty(a)&&a!==b&&(e[a]=I(t[a]));return e}function T(t,e,n){if(!j(e)||!j(t))return n?I(e):t;for(var i in e)if(e.hasOwnProperty(i)&&i!==b){var r=t[i],o=e[i];!j(o)||!j(r)||H(o)||H(r)||$(o)||$(r)||q(o)||q(r)||ct(o)||ct(r)?!n&&i in t||(t[i]=I(e[i])):T(r,o,n)}return t}function C(t,e){for(var n=t[0],i=1,r=t.length;i<r;i++)n=T(n,t[i],e);return n}function D(t,e){if(Object.assign)Object.assign(t,e);else for(var n in e)e.hasOwnProperty(n)&&n!==b&&(t[n]=e[n]);return t}function A(t,e,n){for(var i=F(e),r=0;r<i.length;r++){var o=i[r];(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}return t}var k=u.createCanvas;function L(t,e){if(t){if(t.indexOf)return t.indexOf(e);for(var n=0,i=t.length;n<i;n++)if(t[n]===e)return n}return-1}function P(t,e){var n=t.prototype;function i(){}for(var r in i.prototype=e.prototype,t.prototype=new i,n)n.hasOwnProperty(r)&&(t.prototype[r]=n[r]);t.prototype.constructor=t,t.superClass=e}function O(t,e,n){if(t="prototype"in t?t.prototype:t,e="prototype"in e?e.prototype:e,Object.getOwnPropertyNames)for(var i=Object.getOwnPropertyNames(e),r=0;r<i.length;r++){var o=i[r];"constructor"!==o&&(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}else A(t,e,n)}function R(t){return!!t&&("string"!=typeof t&&"number"==typeof t.length)}function N(t,e,n){if(t&&e)if(Line truncated
/*!
* ZRender, a high performance 2d drawing library.
*
* Copyright (c) 2013, Baidu Inc.
* All rights reserved.
*
* LICENSE
* https://github.com/ecomfe/zrender/blob/master/LICENSE.txt
*/
var Br={},Fr={};var Gr,Wr=function(){function t(t,e,n){var r=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var o=new nn,a=n.renderer||"canvas";Br[a]||(a=F(Br)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var s=new Br[a](e,o,n,t),l=n.ssr||s.ssrOnly;this.storage=o,this.painter=s;var u,h=i.node||i.worker||l?null:new rr(s.getViewportRoot(),s.root),c=n.useCoarsePointer;(null==c||"auto"===c?i.touchEventsSupported:!!c)&&(u=it(n.pointerSize,44)),this.handler=new He(o,s,h,s.root,u),this.animation=new Gi({stage:{update:l?null:function(){return r._flush(!0)}}}),l||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return ri(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r<i;r++)n+=ri(e[r].color,1);return(n/=i)<.4}return!1}(t))},t.prototype.getBackgroundColor=function(){return this._backgroundColor},t.prototype.setDarkMode=function(t){this._darkMode=t},t.prototype.isDarkMode=function(){return this._darkMode},t.prototype.refreshImmediately=function(t){this._disposed||(t||this.animation.update(!0),this._needsRefresh=!1,this.painter.refresh(),this._needsRefresh=!1)},t.prototype.refresh=function(){this._disposed||(this._needsRefresh=!0,this.animation.start())},t.prototype.flush=function(){this._disposed||this._flush(!1)},t.prototype._flush=function(t){var e,n=Vi();this._needsRefresh&&(e=!0,this.refreshImmediately(t)),this._needsRefreshHover&&(e=!0,this.refreshHoverImmediately());var i=Vi();e?(this._stillFrameAccum=0,this.trigger("rendered",{elapsedTime:i-n})):this._sleepAfterStill>0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e<t.length;e++)t[e]instanceof Vr&&t[e].removeSelfFromZr(this);this.storage.delAllRoots(),this.painter.clear()}},t.prototype.dispose=function(){var t;this._disposed||(this.animation.stop(),this.clear(),this.storage.dispose(),this.painter.dispose(),this.handler.dispose(),this.animation=this.storage=this.painter=this.handler=null,this._disposed=!0,t=this.id,delete Fr[t])},t}();function Hr(t,e){var n=new Wr(S(),t,e);return Fr[n.id]=n,n}function Yr(t,e){Br[t]=e}function Xr(t){if("function"==typeof Gr)return Gr(t)}function Ur(t){Gr=t}var Zr=Object.freeze({__proto__:null,init:Hr,dispose:function(t){t.dispose()},disposeAll:function(){for(var t in Fr)Fr.hasOwnProperty(t)&&Fr[t].dispose();Fr={}},getInstance:function(t){return Fr[t]},registerPainter:Yr,getElementSSRData:Xr,registerSSRDataGetter:Ur,version:"5.6.0"}),jr=1e-4;function qr(t,e,n,i){var r=e[0],o=e[1],a=n[0],s=n[1],l=o-r,u=s-a;if(0===l)return 0===u?a:(a+s)/2;if(i)if(l>0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}function Kr(t,e){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return X(t)?(n=t,n.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e:parseFloat(t):null==t?NaN:+t;var n}function $r(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Jr(t){return t.sort((funLine truncated
+13
View File
@@ -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;
+2
View File
@@ -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
+22
View File
@@ -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 };
}
+2
View File
@@ -0,0 +1,2 @@
var n,l,u,t,i,o,r,f,e,c,s,a,h={},v=[],p=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,y=Array.isArray;function d(n,l){for(var u in l)n[u]=l[u];return n}function w(n){n&&n.parentNode&&n.parentNode.removeChild(n)}function _(l,u,t){var i,o,r,f={};for(r in u)"key"==r?i=u[r]:"ref"==r?o=u[r]:f[r]=u[r];if(arguments.length>2&&(f.children=arguments.length>3?n.call(arguments,2):t),"function"==typeof l&&null!=l.defaultProps)for(r in l.defaultProps)void 0===f[r]&&(f[r]=l.defaultProps[r]);return g(l,f,i,o,null)}function g(n,t,i,o,r){var f={type:n,props:t,key:i,ref:o,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,constructor:void 0,__v:null==r?++u:r,__i:-1,__u:0};return null==r&&null!=l.vnode&&l.vnode(f),f}function m(){return{current:null}}function b(n){return n.children}function k(n,l){this.props=n,this.context=l}function x(n,l){if(null==l)return n.__?x(n.__,n.__i+1):null;for(var u;l<n.__k.length;l++)if(null!=(u=n.__k[l])&&null!=u.__e)return u.__e;return"function"==typeof n.type?x(n):null}function C(n){var l,u;if(null!=(n=n.__)&&null!=n.__c){for(n.__e=n.__c.base=null,l=0;l<n.__k.length;l++)if(null!=(u=n.__k[l])&&null!=u.__e){n.__e=n.__c.base=u.__e;break}return C(n)}}function S(n){(!n.__d&&(n.__d=!0)&&i.push(n)&&!M.__r++||o!==l.debounceRendering)&&((o=l.debounceRendering)||r)(M)}function M(){var n,u,t,o,r,e,c,s;for(i.sort(f);n=i.shift();)n.__d&&(u=i.length,o=void 0,e=(r=(t=n).__v).__e,c=[],s=[],t.__P&&((o=d({},r)).__v=r.__v+1,l.vnode&&l.vnode(o),O(t.__P,o,r,t.__n,t.__P.namespaceURI,32&r.__u?[e]:null,c,null==e?x(r):e,!!(32&r.__u),s),o.__v=r.__v,o.__.__k[o.__i]=o,j(c,o,s),o.__e!=e&&C(o)),i.length>u&&i.sort(f));M.__r=0}function P(n,l,u,t,i,o,r,f,e,c,s){var a,p,y,d,w,_=t&&t.__k||v,g=l.length;for(u.__d=e,$(u,l,_),e=u.__d,a=0;a<g;a++)null!=(y=u.__k[a])&&(p=-1===y.__i?h:_[y.__i]||h,y.__i=a,O(n,y,p,i,o,r,f,e,c,s),d=y.__e,y.ref&&p.ref!=y.ref&&(p.ref&&N(p.ref,null,y),s.push(y.ref,y.__c||d,y)),null==w&&null!=d&&(w=d),65536&y.__u||p.__k===y.__k?e=I(y,e,n):"function"==typeof y.type&&void 0!==y.__d?e=y.__d:d&&(e=d.nextSibling),y.__d=void 0,y.__u&=-196609);u.__d=e,u.__e=w}function $(n,l,u){var t,i,o,r,f,e=l.length,c=u.length,s=c,a=0;for(n.__k=[],t=0;t<e;t++)null!=(i=l[t])&&"boolean"!=typeof i&&"function"!=typeof i?(r=t+a,(i=n.__k[t]="string"==typeof i||"number"==typeof i||"bigint"==typeof i||i.constructor==String?g(null,i,null,null,null):y(i)?g(b,{children:i},null,null,null):void 0===i.constructor&&i.__b>0?g(i.type,i.props,i.key,i.ref?i.ref:null,i.__v):i).__=n,i.__b=n.__b+1,o=null,-1!==(f=i.__i=L(i,u,r,s))&&(s--,(o=u[f])&&(o.__u|=131072)),null==o||null===o.__v?(-1==f&&a--,"function"!=typeof i.type&&(i.__u|=65536)):f!==r&&(f==r-1?a--:f==r+1?a++:(f>r?a--:a++,i.__u|=65536))):i=n.__k[t]=null;if(s)for(t=0;t<c;t++)null!=(o=u[t])&&0==(131072&o.__u)&&(o.__e==n.__d&&(n.__d=x(o)),V(o,o))}function I(n,l,u){var t,i;if("function"==typeof n.type){for(t=n.__k,i=0;t&&i<t.length;i++)t[i]&&(t[i].__=n,l=I(t[i],l,u));return l}n.__e!=l&&(l&&n.type&&!u.contains(l)&&(l=x(n)),u.insertBefore(n.__e,l||null),l=n.__e);do{l=l&&l.nextSibling}while(null!=l&&8===l.nodeType);return l}function H(n,l){return l=l||[],null==n||"boolean"==typeof n||(y(n)?n.some(function(n){H(n,l)}):l.push(n)),l}function L(n,l,u,t){var i=n.key,o=n.type,r=u-1,f=u+1,e=l[u];if(null===e||e&&i==e.key&&o===e.type&&0==(131072&e.__u))return u;if(t>(null!=e&&0==(131072&e.__u)?1:0))for(;r>=0||f<l.length;){if(r>=0){if((e=l[r])&&0==(131072&e.__u)&&i==e.key&&o===e.type)return r;r--}if(f<l.length){if((e=l[f])&&0==(131072&e.__u)&&i==e.key&&o===e.type)return f;f++}}return-1}function T(n,l,u){"-"===l[0]?n.setProperty(l,null==u?"":u):n[l]=null==u?"":"number"!=typeof u||p.test(l)?u:u+"px"}function A(n,l,u,t,i){var o;n:if("style"===l)if("string"==typeof u)n.style.cssText=u;else{if("string"==typeof t&&(n.style.cssText=t=""),t)for(l in t)u&&l in u||T(n.style,l,"");if(u)for(l in u)t&&u[l]===t[l]||T(n.style,l,u[l])}else if("o"===l[0]&&"n"===l[1])o=l!==(l=l.replace(/(PointerCapture)$|Capture$/i,"$1")),l=l.toLowerCase()in n||"onFocusOut"===l||"onFocusIn"===l?l.toLowerCase().slice(2):l.slice(2),n.l||(n.l={}),n.l[l+o]=u,u?t?u.u=t.u:(u.u=e,n.addEventListener(l,o?s:c,o)):n.removeEventListener(l,o?s:c,o);else{if("http://www.w3.org/2000/svg"==i)l=l.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!=l&&"height"!=l&&"href"!=l&&"list"!=l&&"form"!=l&&"tabIndex"!=l&&"download"!=l&&"rowSpan"!=l&&"colSpan"!=l&&"role"!=l&&"popover"!=l&&l in n)try{n[l]=null==u?"":u;break n}catch(n){}"function"==typeof u||(null==u||!1===u&&"-"!==l[4]?n.removeAttribute(l):n.setAttribute(l,"popover"==l&&1==u?"":u))}}function F(n){return function(u){if(this.l){var t=this.l[u.type+n];if(null==u.t)u.t=e++;else if(u.t<t.u)return;return t(l.event?l.event(u):u)}}}function O(n,u,t,i,o,r,f,e,c,s){var a,h,v,p,w,_,g,m,x,C,S,M,$,I,H,L,T=u.type;if(void 0!==u.constructor)return null;128&t.__u&&(c=!!(32&t.__u),r=[e=u.__e=t.__e]),(a=l.__b)&&a(u);n:if("function"==typeof T)try{if(m=u.props,x="prototype"in T&&T.prototype.rendLine truncated
//# sourceMappingURL=preact.module.js.map
+1
View File
@@ -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;}
+6140
View File
File diff suppressed because it is too large. Load diff
-16
View File
@@ -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,
},
},
},
})
+88
View File
@@ -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>