This commit is contained in:
Martino Ferrari
2026-04-25 22:56:09 +02:00
parent 8b548ba1c2
commit 986f6cd6d8
85 changed files with 11479 additions and 5050 deletions
-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

File diff suppressed because one or more lines are too long

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>
);
}