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

-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.
// Run `npm run build` inside the web/ directory before building the binary.
// Package web provides the embedded frontend assets.
// Run `make frontend` (or `make all`) to rebuild before compiling the binary.
package web
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 type { SignalRef, SignalValue, SignalMeta } from './types';
+3
View File
@@ -36,7 +36,10 @@ export interface Widget {
// A complete HMI interface definition
export interface Interface {
id: string;
name: string;
version: number;
w: number;
h: number;
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';
// ── Types ────────────────────────────────────────────────────────────────────
+39 -1
View File
@@ -1,5 +1,40 @@
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.
*
@@ -26,8 +61,11 @@ export function parseInterface(xml: string): Interface {
throw new Error(`Expected root element <interface>, got <${root.tagName}>`);
}
const ifaceId = root.getAttribute('id') ?? '';
const name = root.getAttribute('name') ?? 'Untitled';
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[] = [];
@@ -62,5 +100,5 @@ export function parseInterface(xml: string): Interface {
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": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"extends": "./src/tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "src/preact.d.ts"]
}
-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,
},
},
},
})