Phase 2/4 done, working on phase 3/5
This commit is contained in:
Generated
+1211
-1
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -11,11 +11,19 @@
|
||||
},
|
||||
"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"
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^6.0.0",
|
||||
"uplot": "^1.6.32"
|
||||
}
|
||||
}
|
||||
|
||||
+40
-81
@@ -1,35 +1,32 @@
|
||||
<script lang="ts">
|
||||
let status = $state<'checking' | 'ok' | 'error'>('checking');
|
||||
import { wsClient } from './lib/ws';
|
||||
import ViewMode from './lib/ViewMode.svelte';
|
||||
import EditMode from './lib/EditMode.svelte';
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const resp = await fetch('/healthz');
|
||||
status = resp.ok ? 'ok' : 'error';
|
||||
} catch {
|
||||
status = 'error';
|
||||
}
|
||||
}
|
||||
type AppMode = 'view' | 'edit';
|
||||
let mode = $state<AppMode>('view');
|
||||
|
||||
checkHealth();
|
||||
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>
|
||||
|
||||
<main>
|
||||
<div class="logo">uopi</div>
|
||||
<h1>HMI Server</h1>
|
||||
<p class="subtitle">Web-based monitoring & control interface</p>
|
||||
|
||||
<div class="status" class:ok={status === 'ok'} class:error={status === 'error'}>
|
||||
{#if status === 'checking'}
|
||||
Connecting to server…
|
||||
{:else if status === 'ok'}
|
||||
Server reachable
|
||||
{:else}
|
||||
Cannot reach server
|
||||
{/if}
|
||||
{#if $wsStatus === 'disconnected'}
|
||||
<div class="connection-banner">
|
||||
WebSocket disconnected — reconnecting…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="note">Frontend under construction. See <code>docs/WORK_PLAN.md</code> for progress.</p>
|
||||
</main>
|
||||
{#if mode === 'view'}
|
||||
<ViewMode />
|
||||
{:else}
|
||||
<EditMode />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) {
|
||||
@@ -41,67 +38,29 @@
|
||||
:global(body) {
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
|
||||
main {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -2px;
|
||||
color: #60a5fa;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
.connection-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.note {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #1e293b;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
padding: 0.3rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<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>
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -0,0 +1,214 @@
|
||||
<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>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import InterfaceList from './InterfaceList.svelte';
|
||||
import Canvas from './Canvas.svelte';
|
||||
import { wsClient } from './ws';
|
||||
import { parseInterface } from './xml';
|
||||
import type { Interface } from './types';
|
||||
|
||||
let currentInterface = $state<Interface | null>(null);
|
||||
let parseError = $state<string | null>(null);
|
||||
|
||||
const wsStatus = wsClient.status;
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
parseError = null;
|
||||
currentInterface = parseInterface(xml);
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : 'Failed to parse interface file.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="view-mode">
|
||||
<!-- Top toolbar -->
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="app-name">uopi</span>
|
||||
{#if currentInterface}
|
||||
<span class="iface-name">{currentInterface.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{#if parseError}
|
||||
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class="status-chip" class:connected={$wsStatus === 'connected'} class:disconnected={$wsStatus === 'disconnected'} class:connecting={$wsStatus === 'connecting'}>
|
||||
<span class="status-dot"></span>
|
||||
{$wsStatus}
|
||||
</div>
|
||||
<button class="btn-edit" disabled title="Edit mode — coming in Phase 6">Edit</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content row -->
|
||||
<div class="content">
|
||||
<InterfaceList onLoad={handleLoad} />
|
||||
<Canvas iface={currentInterface} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #0f1117;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
height: 44px;
|
||||
background: #1a1f2e;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #60a5fa;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.iface-name {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.parse-error {
|
||||
font-size: 0.78rem;
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-chip.connected {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
border-color: #166534;
|
||||
}
|
||||
|
||||
.status-chip.disconnected {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
.status-chip.connecting {
|
||||
background: #1e293b;
|
||||
color: #fbbf24;
|
||||
border-color: #92400e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #2d3748;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
import { readable, writable, type Readable } from 'svelte/store';
|
||||
import { wsClient } from './ws';
|
||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||
|
||||
// ── Default values ────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = {
|
||||
value: null,
|
||||
quality: 'unknown',
|
||||
ts: null,
|
||||
};
|
||||
|
||||
// ── History buffer ────────────────────────────────────────────────────────────
|
||||
|
||||
const HISTORY_MAX = 1000;
|
||||
|
||||
export interface HistoryPoint {
|
||||
ts: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
const historyBuffers = new Map<string, HistoryPoint[]>();
|
||||
|
||||
function pushHistory(key: string, v: SignalValue): void {
|
||||
if (v.value === null || v.value === undefined || v.ts === null) return;
|
||||
let buf = historyBuffers.get(key);
|
||||
if (!buf) {
|
||||
buf = [];
|
||||
historyBuffers.set(key, buf);
|
||||
}
|
||||
buf.push({ ts: v.ts, value: v.value });
|
||||
if (buf.length > HISTORY_MAX) buf.splice(0, buf.length - HISTORY_MAX);
|
||||
}
|
||||
|
||||
export function getSignalHistory(ref: SignalRef): HistoryPoint[] {
|
||||
const key = `${ref.ds}\0${ref.name}`;
|
||||
return historyBuffers.get(key) ?? [];
|
||||
}
|
||||
|
||||
// ── Store maps (keyed by "ds\0name") ─────────────────────────────────────────
|
||||
|
||||
const valueStores = new Map<string, Readable<SignalValue>>();
|
||||
const metaStores = new Map<string, Readable<SignalMeta | null>>();
|
||||
|
||||
function refKey(ref: SignalRef): string {
|
||||
return `${ref.ds}\0${ref.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Svelte readable store that delivers live signal values.
|
||||
* Created lazily; the WS subscription is started on first use.
|
||||
* The store stays alive once created (simplest correct behaviour for Phase 4).
|
||||
*/
|
||||
export function getSignalStore(ref: SignalRef): Readable<SignalValue> {
|
||||
const key = refKey(ref);
|
||||
const existing = valueStores.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const store = readable<SignalValue>(DEFAULT_VALUE, (set) => {
|
||||
// metaStore may already exist; we need a shared WS subscription.
|
||||
// We use a shared subscription via _createShared.
|
||||
const unsub = _getShared(ref).subscribeValue(set);
|
||||
return unsub;
|
||||
});
|
||||
|
||||
valueStores.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Svelte readable store that delivers signal metadata.
|
||||
* Metadata is sent once by the server on subscribe.
|
||||
*/
|
||||
export function getMetaStore(ref: SignalRef): Readable<SignalMeta | null> {
|
||||
const key = refKey(ref);
|
||||
const existing = metaStores.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const store = readable<SignalMeta | null>(null, (set) => {
|
||||
const unsub = _getShared(ref).subscribeMeta(set);
|
||||
return unsub;
|
||||
});
|
||||
|
||||
metaStores.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
// ── Shared subscription helper ────────────────────────────────────────────────
|
||||
//
|
||||
// Both getSignalStore and getMetaStore need to share one WS subscription per
|
||||
// signal. This object manages that sharing.
|
||||
|
||||
interface SharedEntry {
|
||||
subscribeValue: (set: (v: SignalValue) => void) => () => void;
|
||||
subscribeMeta: (set: (m: SignalMeta | null) => void) => () => void;
|
||||
}
|
||||
|
||||
const sharedEntries = new Map<string, SharedEntry>();
|
||||
|
||||
function _getShared(ref: SignalRef): SharedEntry {
|
||||
const key = refKey(ref);
|
||||
const existing = sharedEntries.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
// Internal writables that fan out to all store subscribers
|
||||
const valueW = writable<SignalValue>(DEFAULT_VALUE);
|
||||
const metaW = writable<SignalMeta | null>(null);
|
||||
|
||||
// Reference count for the WS subscription
|
||||
let wsUnsub: (() => void) | null = null;
|
||||
let refCount = 0;
|
||||
|
||||
function addRef() {
|
||||
if (refCount === 0) {
|
||||
wsUnsub = wsClient.subscribe(
|
||||
ref,
|
||||
(v) => {
|
||||
valueW.set(v);
|
||||
pushHistory(key, v);
|
||||
},
|
||||
(m) => metaW.set(m),
|
||||
);
|
||||
}
|
||||
refCount++;
|
||||
}
|
||||
|
||||
function removeRef() {
|
||||
refCount--;
|
||||
if (refCount <= 0 && wsUnsub) {
|
||||
wsUnsub();
|
||||
wsUnsub = null;
|
||||
refCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const entry: SharedEntry = {
|
||||
subscribeValue(set) {
|
||||
addRef();
|
||||
const unsubStore = valueW.subscribe(set);
|
||||
return () => {
|
||||
unsubStore();
|
||||
removeRef();
|
||||
};
|
||||
},
|
||||
subscribeMeta(set) {
|
||||
addRef();
|
||||
const unsubStore = metaW.subscribe(set);
|
||||
return () => {
|
||||
unsubStore();
|
||||
removeRef();
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
sharedEntries.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Core signal reference: identifies a signal by datasource + name
|
||||
export interface SignalRef {
|
||||
ds: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Live value for a signal
|
||||
export interface SignalValue {
|
||||
value: any;
|
||||
quality: 'good' | 'uncertain' | 'bad' | 'unknown';
|
||||
ts: string | null;
|
||||
}
|
||||
|
||||
// Metadata for a signal (received once on subscribe)
|
||||
export interface SignalMeta {
|
||||
type: string;
|
||||
unit?: string;
|
||||
displayLow: number;
|
||||
displayHigh: number;
|
||||
writable: boolean;
|
||||
enumStrings?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// A single widget in an interface
|
||||
export interface Widget {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
signals: Array<SignalRef & { color?: string }>;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
// A complete HMI interface definition
|
||||
export interface Interface {
|
||||
name: string;
|
||||
version: number;
|
||||
widgets: Widget[];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<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>
|
||||
@@ -0,0 +1,136 @@
|
||||
<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>
|
||||
@@ -0,0 +1,66 @@
|
||||
<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>
|
||||
@@ -0,0 +1,187 @@
|
||||
<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>
|
||||
@@ -0,0 +1,99 @@
|
||||
<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>
|
||||
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
@@ -0,0 +1,378 @@
|
||||
<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>
|
||||
@@ -0,0 +1,170 @@
|
||||
<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>
|
||||
@@ -0,0 +1,99 @@
|
||||
<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>
|
||||
@@ -0,0 +1,206 @@
|
||||
import { readable, writable, type Readable } from 'svelte/store';
|
||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type WsStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
interface SubscriberEntry {
|
||||
onUpdate: (v: SignalValue) => void;
|
||||
onMeta: (m: SignalMeta) => void;
|
||||
}
|
||||
|
||||
// ── WsClient ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class WsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url = '';
|
||||
|
||||
// Reconnect back-off state
|
||||
private retryDelay = 500;
|
||||
private readonly maxDelay = 30_000;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private intentionalClose = false;
|
||||
|
||||
// Status store
|
||||
private _statusStore = writable<WsStatus>('connecting');
|
||||
readonly status: Readable<WsStatus> = { subscribe: this._statusStore.subscribe };
|
||||
|
||||
// Subscription tracking: key is "ds\0name"
|
||||
// ref-count: how many callers are subscribed
|
||||
private refCounts = new Map<string, number>();
|
||||
// subscriber callbacks per signal
|
||||
private subscribers = new Map<string, Set<SubscriberEntry>>();
|
||||
|
||||
connect(url: string): void {
|
||||
this.url = url;
|
||||
this.intentionalClose = false;
|
||||
this._open();
|
||||
}
|
||||
|
||||
private _open(): void {
|
||||
if (this.intentionalClose) return;
|
||||
|
||||
this._statusStore.set('connecting');
|
||||
|
||||
try {
|
||||
// Resolve relative URL to absolute ws:// URL
|
||||
const wsUrl = this._resolveWsUrl(this.url);
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.addEventListener('open', () => {
|
||||
this.retryDelay = 500; // reset back-off on success
|
||||
this._statusStore.set('connected');
|
||||
|
||||
// Re-subscribe all active signals after reconnect
|
||||
const signals: Array<{ ds: string; name: string }> = [];
|
||||
for (const key of this.refCounts.keys()) {
|
||||
if ((this.refCounts.get(key) ?? 0) > 0) {
|
||||
const [ds, name] = key.split('\0');
|
||||
signals.push({ ds, name });
|
||||
}
|
||||
}
|
||||
if (signals.length > 0) {
|
||||
this._send({ type: 'subscribe', signals });
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('message', (ev) => {
|
||||
this._handleMessage(ev.data as string);
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
this._statusStore.set('disconnected');
|
||||
if (!this.intentionalClose) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
// The close event fires right after, which will handle the reconnect.
|
||||
this._statusStore.set('disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
private _resolveWsUrl(path: string): string {
|
||||
if (path.startsWith('ws://') || path.startsWith('wss://')) {
|
||||
return path;
|
||||
}
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${location.host}${path}`;
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
if (this.retryTimer !== null) return;
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.retryDelay = Math.min(this.retryDelay * 2, this.maxDelay);
|
||||
this._open();
|
||||
}, this.retryDelay);
|
||||
}
|
||||
|
||||
private _send(obj: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMessage(data: string): void {
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = msg.ds && msg.name ? `${msg.ds}\0${msg.name}` : '';
|
||||
|
||||
switch (msg.type) {
|
||||
case 'update': {
|
||||
const subs = this.subscribers.get(key);
|
||||
if (!subs) break;
|
||||
const val: SignalValue = {
|
||||
value: msg.value,
|
||||
quality: msg.quality ?? 'unknown',
|
||||
ts: msg.ts ?? null,
|
||||
};
|
||||
for (const s of subs) s.onUpdate(val);
|
||||
break;
|
||||
}
|
||||
case 'meta': {
|
||||
const subs = this.subscribers.get(key);
|
||||
if (!subs || !msg.meta) break;
|
||||
const meta: SignalMeta = {
|
||||
type: msg.meta.type ?? 'unknown',
|
||||
unit: msg.meta.unit,
|
||||
displayLow: msg.meta.displayLow ?? 0,
|
||||
displayHigh: msg.meta.displayHigh ?? 100,
|
||||
writable: msg.meta.writable ?? false,
|
||||
enumStrings: msg.meta.enumStrings,
|
||||
description: msg.meta.description,
|
||||
};
|
||||
for (const s of subs) s.onMeta(meta);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.warn('[ws] server error', msg.code, msg.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a signal. Reference-counted: only one WS subscribe message
|
||||
* is sent even if multiple callers subscribe to the same signal.
|
||||
* Returns a cleanup function that unsubscribes when all callers are done.
|
||||
*/
|
||||
subscribe(
|
||||
ref: SignalRef,
|
||||
onUpdate: (v: SignalValue) => void,
|
||||
onMeta: (m: SignalMeta) => void,
|
||||
): () => void {
|
||||
const key = `${ref.ds}\0${ref.name}`;
|
||||
const entry: SubscriberEntry = { onUpdate, onMeta };
|
||||
|
||||
// Add to subscriber set
|
||||
let set = this.subscribers.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.subscribers.set(key, set);
|
||||
}
|
||||
set.add(entry);
|
||||
|
||||
// Reference counting — send WS subscribe only on first subscriber
|
||||
const prev = this.refCounts.get(key) ?? 0;
|
||||
this.refCounts.set(key, prev + 1);
|
||||
if (prev === 0) {
|
||||
this._send({ type: 'subscribe', signals: [{ ds: ref.ds, name: ref.name }] });
|
||||
}
|
||||
|
||||
return () => {
|
||||
const subs = this.subscribers.get(key);
|
||||
subs?.delete(entry);
|
||||
|
||||
const count = (this.refCounts.get(key) ?? 0) - 1;
|
||||
this.refCounts.set(key, Math.max(0, count));
|
||||
|
||||
if (count <= 0) {
|
||||
this.subscribers.delete(key);
|
||||
this.refCounts.delete(key);
|
||||
this._send({ type: 'unsubscribe', signals: [{ ds: ref.ds, name: ref.name }] });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
write(ref: SignalRef, value: any): void {
|
||||
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const wsClient = new WsClient();
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Interface, Widget, SignalRef } from './types';
|
||||
|
||||
/**
|
||||
* Parse an interface XML string into an Interface object.
|
||||
*
|
||||
* Expected XML format:
|
||||
* <interface name="..." version="1">
|
||||
* <widget id="w1" type="textview" x="100" y="50" w="200" h="60">
|
||||
* <signal ds="stub" name="sine_1hz" color="#ff0000"/>
|
||||
* <option key="label" value="Sine 1Hz"/>
|
||||
* </widget>
|
||||
* </interface>
|
||||
*/
|
||||
export function parseInterface(xml: string): Interface {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'application/xml');
|
||||
|
||||
// Check for parse errors
|
||||
const parseError = doc.querySelector('parsererror');
|
||||
if (parseError) {
|
||||
throw new Error(`XML parse error: ${parseError.textContent}`);
|
||||
}
|
||||
|
||||
const root = doc.documentElement;
|
||||
if (root.tagName !== 'interface') {
|
||||
throw new Error(`Expected root element <interface>, got <${root.tagName}>`);
|
||||
}
|
||||
|
||||
const name = root.getAttribute('name') ?? 'Untitled';
|
||||
const version = parseInt(root.getAttribute('version') ?? '1', 10);
|
||||
|
||||
const widgets: Widget[] = [];
|
||||
|
||||
for (const el of Array.from(root.children)) {
|
||||
if (el.tagName !== 'widget') continue;
|
||||
|
||||
const id = el.getAttribute('id') ?? '';
|
||||
const type = el.getAttribute('type') ?? 'unknown';
|
||||
const x = parseFloat(el.getAttribute('x') ?? '0');
|
||||
const y = parseFloat(el.getAttribute('y') ?? '0');
|
||||
const w = parseFloat(el.getAttribute('w') ?? '100');
|
||||
const h = parseFloat(el.getAttribute('h') ?? '60');
|
||||
|
||||
const signals: Array<SignalRef & { color?: string }> = [];
|
||||
const options: Record<string, string> = {};
|
||||
|
||||
for (const child of Array.from(el.children)) {
|
||||
if (child.tagName === 'signal') {
|
||||
const ds = child.getAttribute('ds') ?? '';
|
||||
const sigName = child.getAttribute('name') ?? '';
|
||||
const color = child.getAttribute('color') ?? undefined;
|
||||
signals.push({ ds, name: sigName, ...(color ? { color } : {}) });
|
||||
} else if (child.tagName === 'option') {
|
||||
const key = child.getAttribute('key');
|
||||
const value = child.getAttribute('value');
|
||||
if (key !== null && value !== null) {
|
||||
options[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
widgets.push({ id, type, x, y, w, h, signals, options });
|
||||
}
|
||||
|
||||
return { name, version, widgets };
|
||||
}
|
||||
@@ -7,6 +7,10 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/healthz': 'http://localhost:8080',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user