Phase 6
This commit is contained in:
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { wsClient } from './lib/ws';
|
||||
import ViewMode from './lib/ViewMode.svelte';
|
||||
import EditMode from './lib/EditMode.svelte';
|
||||
|
||||
type AppMode = 'view' | 'edit';
|
||||
let mode = $state<AppMode>('view');
|
||||
|
||||
const wsStatus = wsClient.status;
|
||||
|
||||
// Set CSS variable for device pixel ratio on the root element
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||
|
||||
// Connect to WebSocket — called once at app startup
|
||||
wsClient.connect('/ws');
|
||||
</script>
|
||||
|
||||
{#if $wsStatus === 'disconnected'}
|
||||
<div class="connection-banner">
|
||||
WebSocket disconnected — reconnecting…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'view'}
|
||||
<ViewMode />
|
||||
{:else}
|
||||
<EditMode />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(#app) {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
border: none;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.connection-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from './lib/ws';
|
||||
import ViewMode from './ViewMode';
|
||||
import EditMode from './EditMode';
|
||||
import type { Interface } from './lib/types';
|
||||
|
||||
type AppMode = 'view' | 'edit';
|
||||
|
||||
export default function App() {
|
||||
const [mode, setMode] = useState<AppMode>('view');
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||
wsClient.connect('/ws');
|
||||
}, []);
|
||||
|
||||
function enterEdit(iface?: Interface) {
|
||||
setEditTarget(iface ?? null);
|
||||
setMode('edit');
|
||||
}
|
||||
|
||||
function exitEdit() {
|
||||
setMode('view');
|
||||
setEditTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view'
|
||||
? <ViewMode onEdit={enterEdit} />
|
||||
: <EditMode initial={editTarget} onDone={exitEdit} />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||
import TextView from './widgets/TextView';
|
||||
import TextLabel from './widgets/TextLabel';
|
||||
import Gauge from './widgets/Gauge';
|
||||
import BarH from './widgets/BarH';
|
||||
import BarV from './widgets/BarV';
|
||||
import Led from './widgets/Led';
|
||||
import MultiLed from './widgets/MultiLed';
|
||||
import SetValue from './widgets/SetValue';
|
||||
import Button from './widgets/Button';
|
||||
import PlotWidget from './widgets/PlotWidget';
|
||||
import ImageWidget from './widgets/ImageWidget';
|
||||
import LinkWidget from './widgets/LinkWidget';
|
||||
import ContextMenu from './ContextMenu';
|
||||
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
textview: TextView,
|
||||
textlabel: TextLabel,
|
||||
gauge: Gauge,
|
||||
barh: BarH,
|
||||
barv: BarV,
|
||||
led: Led,
|
||||
multiled: MultiLed,
|
||||
setvalue: SetValue,
|
||||
button: Button,
|
||||
plot: PlotWidget,
|
||||
image: ImageWidget,
|
||||
link: LinkWidget,
|
||||
};
|
||||
|
||||
interface CtxState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
signal: SignalRef | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
iface: Interface | null;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
}
|
||||
|
||||
export default function Canvas({ iface, onNavigate }: Props) {
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxState>({
|
||||
visible: false, x: 0, y: 0, signal: null,
|
||||
});
|
||||
|
||||
function onCtxMenu(e: MouseEvent, widget: Widget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null });
|
||||
}
|
||||
|
||||
if (!iface) {
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
<div class="placeholder">
|
||||
<p>Select an interface from the left panel, or import one.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
? <Comp
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
: (
|
||||
<div
|
||||
key={widget.id}
|
||||
class="unknown-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
title={`Unknown widget type: ${widget.type}`}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
>
|
||||
<span class="unknown-label">{widget.type}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ContextMenu
|
||||
{...ctxMenu}
|
||||
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { SignalRef, SignalMeta } from './lib/types';
|
||||
import { getMetaStore } from './lib/stores';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
signal: SignalRef | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signal) { setMeta(null); return; }
|
||||
const unsub = getMetaStore(signal).subscribe(setMeta);
|
||||
return unsub;
|
||||
}, [signal?.ds, signal?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [visible, onClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
function copySignalName() {
|
||||
if (signal) {
|
||||
navigator.clipboard.writeText(signal.name).catch(() => {});
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// Phase 5+: export CSV data
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="ctx-menu"
|
||||
ref={menuRef}
|
||||
style={`left:${x}px;top:${y}px;`}
|
||||
>
|
||||
{signal ? (
|
||||
<div>
|
||||
<div class="ctx-signal-info">
|
||||
<div>{signal.ds} / {signal.name}</div>
|
||||
{meta && (
|
||||
<div>{meta.type}{meta.unit ? ` [${meta.unit}]` : ''}</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
|
||||
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||
</div>
|
||||
) : (
|
||||
<button class="ctx-item" onClick={onClose}>Close</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { h } from 'preact';
|
||||
import { useRef, useEffect, useState } from 'preact/hooks';
|
||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||
import TextView from './widgets/TextView';
|
||||
import TextLabel from './widgets/TextLabel';
|
||||
import Gauge from './widgets/Gauge';
|
||||
import BarH from './widgets/BarH';
|
||||
import BarV from './widgets/BarV';
|
||||
import Led from './widgets/Led';
|
||||
import MultiLed from './widgets/MultiLed';
|
||||
import SetValue from './widgets/SetValue';
|
||||
import Button from './widgets/Button';
|
||||
import PlotWidget from './widgets/PlotWidget';
|
||||
import ImageWidget from './widgets/ImageWidget';
|
||||
import LinkWidget from './widgets/LinkWidget';
|
||||
import WidgetTypePicker from './WidgetTypePicker';
|
||||
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
textview: TextView, textlabel: TextLabel, gauge: Gauge,
|
||||
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
|
||||
setvalue: SetValue, button: Button, plot: PlotWidget,
|
||||
image: ImageWidget, link: LinkWidget,
|
||||
};
|
||||
|
||||
const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
textview: [200, 50],
|
||||
textlabel: [150, 36],
|
||||
gauge: [120, 120],
|
||||
barh: [200, 60],
|
||||
barv: [60, 160],
|
||||
led: [60, 60],
|
||||
multiled: [200, 80],
|
||||
setvalue: [200, 60],
|
||||
button: [120, 50],
|
||||
plot: [400, 200],
|
||||
image: [200, 150],
|
||||
link: [140, 50],
|
||||
};
|
||||
|
||||
interface PickerState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
signal: SignalRef | null;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
widgetId: string;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startWidgetX: number;
|
||||
startWidgetY: number;
|
||||
}
|
||||
|
||||
interface ResizeState {
|
||||
widgetId: string;
|
||||
handle: string; // 'se' | 'sw' | 'ne' | 'nw' | 'n' | 's' | 'e' | 'w'
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startW: number;
|
||||
startH: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
iface: Interface;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onChange: (widgets: Widget[]) => void;
|
||||
}
|
||||
|
||||
let nextId = Date.now();
|
||||
function genId() { return `w${nextId++}`; }
|
||||
|
||||
export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
const [picker, setPicker] = useState<PickerState>({
|
||||
visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null,
|
||||
});
|
||||
|
||||
// Document-level move/up handlers for drag and resize
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (dragRef.current) {
|
||||
const d = dragRef.current;
|
||||
const dx = e.clientX - d.startMouseX;
|
||||
const dy = e.clientY - d.startMouseY;
|
||||
const widgets = iface.widgets.map(w =>
|
||||
w.id === d.widgetId
|
||||
? { ...w, x: Math.round(d.startWidgetX + dx), y: Math.round(d.startWidgetY + dy) }
|
||||
: w
|
||||
);
|
||||
onChange(widgets);
|
||||
}
|
||||
if (resizeRef.current) {
|
||||
const r = resizeRef.current;
|
||||
const dx = e.clientX - r.startMouseX;
|
||||
const dy = e.clientY - r.startMouseY;
|
||||
let { startX: x, startY: y, startW: w, startH: h } = r;
|
||||
const handle = r.handle;
|
||||
if (handle.includes('e')) w = Math.max(20, w + dx);
|
||||
if (handle.includes('s')) h = Math.max(20, h + dy);
|
||||
if (handle.includes('w')) { x = x + dx; w = Math.max(20, w - dx); }
|
||||
if (handle.includes('n')) { y = y + dy; h = Math.max(20, h - dy); }
|
||||
const widgets = iface.widgets.map(widget =>
|
||||
widget.id === r.widgetId
|
||||
? { ...widget, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }
|
||||
: widget
|
||||
);
|
||||
onChange(widgets);
|
||||
}
|
||||
}
|
||||
function onMouseUp() {
|
||||
dragRef.current = null;
|
||||
resizeRef.current = null;
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, [iface.widgets, onChange]);
|
||||
|
||||
function handleCanvasClick(e: MouseEvent) {
|
||||
if ((e.target as Element).closest('.widget-overlay')) return;
|
||||
onSelect(null);
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
const json = e.dataTransfer?.getData('application/json');
|
||||
if (!json) return;
|
||||
let sig: SignalRef;
|
||||
try { sig = JSON.parse(json); } catch { return; }
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const scrollLeft = containerRef.current?.scrollLeft ?? 0;
|
||||
const scrollTop = containerRef.current?.scrollTop ?? 0;
|
||||
const cx = e.clientX - rect.left + scrollLeft;
|
||||
const cy = e.clientY - rect.top + scrollTop;
|
||||
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
|
||||
}
|
||||
|
||||
function handlePick(type: string) {
|
||||
if (!picker.signal) return;
|
||||
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||
const newWidget: Widget = {
|
||||
id: genId(),
|
||||
type,
|
||||
x: Math.round(picker.canvasX - defW / 2),
|
||||
y: Math.round(picker.canvasY - defH / 2),
|
||||
w: defW,
|
||||
h: defH,
|
||||
signals: [picker.signal],
|
||||
options: {},
|
||||
};
|
||||
onChange([...iface.widgets, newWidget]);
|
||||
onSelect(newWidget.id);
|
||||
setPicker(p => ({ ...p, visible: false }));
|
||||
}
|
||||
|
||||
function handleDeleteWidget(id: string) {
|
||||
onChange(iface.widgets.filter(w => w.id !== id));
|
||||
if (selectedId === id) onSelect(null);
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent, widget: Widget) {
|
||||
e.stopPropagation();
|
||||
onSelect(widget.id);
|
||||
dragRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startWidgetX: widget.x,
|
||||
startWidgetY: widget.y,
|
||||
};
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent, widget: Widget, handle: string) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
resizeRef.current = {
|
||||
widgetId: widget.id,
|
||||
handle,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startX: widget.x,
|
||||
startY: widget.y,
|
||||
startW: widget.w,
|
||||
startH: widget.h,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="edit-canvas-container"
|
||||
ref={containerRef}
|
||||
onClick={handleCanvasClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
class="canvas-area"
|
||||
style={`width:${iface.w}px;height:${iface.h}px;`}
|
||||
>
|
||||
{/* Render live widget previews */}
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
? <Comp key={widget.id} widget={widget} />
|
||||
: (
|
||||
<div
|
||||
key={widget.id}
|
||||
class="unknown-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
>
|
||||
<span class="unknown-label">{widget.type}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Overlay divs for interaction — sit above the live widgets */}
|
||||
{iface.widgets.map(widget => {
|
||||
const isSelected = widget.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={`overlay-${widget.id}`}
|
||||
class={`widget-overlay${isSelected ? ' selected' : ''}`}
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onMouseDown={(e: MouseEvent) => startDrag(e, widget)}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); onSelect(widget.id); }}
|
||||
>
|
||||
{isSelected && (
|
||||
<div>
|
||||
{/* Delete button */}
|
||||
<button
|
||||
class="overlay-delete"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteWidget(widget.id); }}
|
||||
title="Delete widget"
|
||||
>✕</button>
|
||||
{/* Resize handles */}
|
||||
{(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
|
||||
<div
|
||||
key={dir}
|
||||
class={`resize-handle resize-${dir}`}
|
||||
onMouseDown={(e: MouseEvent) => startResize(e, widget, dir)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{picker.visible && (
|
||||
<WidgetTypePicker
|
||||
x={picker.x}
|
||||
y={picker.y}
|
||||
onPick={handlePick}
|
||||
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useCallback } from 'preact/hooks';
|
||||
import type { Interface, Widget } from './lib/types';
|
||||
import { serializeInterface, parseInterface } from './lib/xml';
|
||||
import SignalTree from './SignalTree';
|
||||
import EditCanvas from './EditCanvas';
|
||||
import PropertiesPane from './PropertiesPane';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
let _newId = Date.now();
|
||||
function newIfaceId() { return `iface-${_newId++}`; }
|
||||
|
||||
function blankInterface(): Interface {
|
||||
return {
|
||||
id: newIfaceId(),
|
||||
name: 'New Interface',
|
||||
version: 1,
|
||||
w: 1200,
|
||||
h: 800,
|
||||
widgets: [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedWidget = iface.widgets.find(w => w.id === selectedId) ?? null;
|
||||
|
||||
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
|
||||
setIface(f => ({ ...f, widgets }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleWidgetChange = useCallback((updated: Widget) => {
|
||||
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleIfaceChange = useCallback((updated: Interface) => {
|
||||
setIface(updated);
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const xml = serializeInterface(iface);
|
||||
const url = iface.id
|
||||
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
|
||||
: '/api/v1/interfaces';
|
||||
const method = iface.id ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
body: xml,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Save failed (${res.status}): ${text}`);
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const json = await res.json();
|
||||
setIface(f => ({ ...f, id: json.id }));
|
||||
}
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const xml = serializeInterface(iface);
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.xml,application/xml,text/xml';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const xml = await file.text();
|
||||
const parsed = parseInterface(xml);
|
||||
setIface(parsed);
|
||||
setSelectedId(null);
|
||||
setDirty(true);
|
||||
} catch (err) {
|
||||
setError(`Import failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||
onDone();
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedId) return;
|
||||
setIface(f => ({ ...f, widgets: f.widgets.filter(w => w.id !== selectedId) }));
|
||||
setSelectedId(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId &&
|
||||
!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
|
||||
handleDeleteSelected();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||
{/* Toolbar */}
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="app-name">uopi</span>
|
||||
<span class="edit-badge">Edit</span>
|
||||
<input
|
||||
class="iface-name-input"
|
||||
value={iface.name}
|
||||
onInput={(e) => handleIfaceChange({ ...iface, name: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
{dirty && <span class="dirty-dot" title="Unsaved changes">●</span>}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{error && <span class="parse-error" title={error}>Save error — check console</span>}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="toolbar-btn" onClick={handleImport} title="Import XML file">Import</button>
|
||||
<button class="toolbar-btn" onClick={handleExport} title="Export XML file">Export</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button class="toolbar-btn" onClick={handleLeave} title="Back to view mode">✕ Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Three-panel body */}
|
||||
<div class="edit-body">
|
||||
<SignalTree />
|
||||
<EditCanvas
|
||||
iface={iface}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onChange={handleWidgetsChange}
|
||||
/>
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { Interface } from './lib/types';
|
||||
|
||||
interface ServerInterface {
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onLoad: (xml: string) => void;
|
||||
onSelect?: (id: string) => void;
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
}
|
||||
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function refresh() {
|
||||
setLoading(true);
|
||||
fetch('/api/v1/interfaces')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => setInterfaces(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
function triggerImport() {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
|
||||
async function handleFileChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const xml = await file.text();
|
||||
onLoad(xml);
|
||||
} catch (err) {
|
||||
console.error('Failed to read interface file:', err);
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete interface "${name}"?`)) return;
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
async function handleClone(id: string) {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}/clone`, { method: 'POST' });
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Interfaces</span>}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={collapsed ? 'Expand panel' : 'Collapse panel'}
|
||||
>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
|
||||
<button class="panel-btn" onClick={triggerImport}>Import</button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style="display:none"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : interfaces.length === 0 ? (
|
||||
<p class="hint">No interfaces yet. Create one or import XML.</p>
|
||||
) : (
|
||||
<ul class="iface-list">
|
||||
{interfaces.map(iface => (
|
||||
<li key={iface.id} class="iface-item">
|
||||
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
|
||||
{iface.name || iface.id}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="Edit" onClick={() => onEdit?.()}>✎</button>
|
||||
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}>⎘</button>
|
||||
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}>✕</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { Widget, Interface } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
selected: Widget | null;
|
||||
iface: Interface;
|
||||
onChange: (updated: Widget) => void;
|
||||
onIfaceChange: (updated: Interface) => void;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: any }) {
|
||||
return (
|
||||
<div class="prop-field">
|
||||
<label class="prop-label">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [local, setLocal] = useState(value);
|
||||
return (
|
||||
<input
|
||||
class="prop-input"
|
||||
value={local}
|
||||
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
|
||||
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PropertiesPane({ selected, iface, onChange, onIfaceChange }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
function setOpt(key: string, value: string) {
|
||||
if (!selected) return;
|
||||
onChange({ ...selected, options: { ...selected.options, [key]: value } });
|
||||
}
|
||||
|
||||
function setProp(key: keyof Widget, value: any) {
|
||||
if (!selected) return;
|
||||
onChange({ ...selected, [key]: value } as Widget);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Properties</span>}
|
||||
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
{collapsed ? '◀' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div class="props-body">
|
||||
{/* Interface-level properties (always shown) */}
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">Canvas</div>
|
||||
<Field label="Name">
|
||||
<TextInput
|
||||
value={iface.name}
|
||||
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Width">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.w}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Height">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.h}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">{selected.type}</div>
|
||||
<Field label="X">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.x}
|
||||
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
||||
</Field>
|
||||
<Field label="Y">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.y}
|
||||
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
|
||||
</Field>
|
||||
<Field label="Width">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.w} min={10}
|
||||
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
||||
</Field>
|
||||
<Field label="Height">
|
||||
<input class="prop-input prop-input-num" type="number" value={selected.h} min={10}
|
||||
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
|
||||
</Field>
|
||||
|
||||
{/* Signal */}
|
||||
{selected.signals.length > 0 && (
|
||||
<Field label="Signal">
|
||||
<span class="prop-sig">{selected.signals[0].ds}:{selected.signals[0].name}</span>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Common options */}
|
||||
<Field label="Label">
|
||||
<TextInput
|
||||
value={selected.options['label'] ?? ''}
|
||||
onCommit={(v) => setOpt('label', v)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Type-specific options */}
|
||||
{(selected.type === 'gauge' || selected.type === 'barh' || selected.type === 'barv') && (
|
||||
<div>
|
||||
<Field label="Min">
|
||||
<TextInput value={selected.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
|
||||
</Field>
|
||||
<Field label="Max">
|
||||
<TextInput value={selected.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'led' && (
|
||||
<Field label="Condition">
|
||||
<TextInput value={selected.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selected.type === 'setvalue' && (
|
||||
<Field label="Min">
|
||||
<TextInput value={selected.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selected.type === 'plot' && (
|
||||
<Field label="Plot type">
|
||||
<select
|
||||
class="prop-select"
|
||||
value={selected.options['plotType'] ?? 'timeseries'}
|
||||
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="timeseries">Time-series</option>
|
||||
<option value="histogram">Histogram</option>
|
||||
<option value="bar">Bar chart</option>
|
||||
<option value="fft">FFT</option>
|
||||
<option value="waterfall">Waterfall</option>
|
||||
<option value="logic">Logic analyser</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selected.type === 'textlabel' && (
|
||||
<div>
|
||||
<Field label="Font size">
|
||||
<TextInput value={selected.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
|
||||
</Field>
|
||||
<Field label="Color">
|
||||
<TextInput value={selected.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === 'image' && (
|
||||
<Field label="URL">
|
||||
<TextInput value={selected.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{selected.type === 'link' && (
|
||||
<div>
|
||||
<Field label="Target">
|
||||
<TextInput value={selected.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
|
||||
</Field>
|
||||
<Field label="Label">
|
||||
<TextInput value={selected.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selected && (
|
||||
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
writable: boolean;
|
||||
}
|
||||
|
||||
interface DsGroup {
|
||||
ds: string;
|
||||
signals: SignalInfo[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onDragStart?: (sig: SignalRef) => void;
|
||||
}
|
||||
|
||||
export default function SignalTree({ onDragStart }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [groups, setGroups] = useState<DsGroup[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasources');
|
||||
if (!res.ok) return;
|
||||
const dsList: { name: string }[] = await res.json();
|
||||
const loaded: DsGroup[] = await Promise.all(
|
||||
dsList.map(async ({ name }) => {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
||||
const sigs: SignalInfo[] = r.ok ? await r.json() : [];
|
||||
return { ds: name, signals: sigs, expanded: true };
|
||||
} catch {
|
||||
return { ds: name, signals: [], expanded: true };
|
||||
}
|
||||
})
|
||||
);
|
||||
setGroups(loaded);
|
||||
} catch (e) {
|
||||
console.error('Failed to load signals:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
function toggleGroup(ds: string) {
|
||||
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
|
||||
}
|
||||
|
||||
const lf = filter.toLowerCase();
|
||||
const filtered = groups.map(g => ({
|
||||
...g,
|
||||
signals: lf ? g.signals.filter(s => s.name.toLowerCase().includes(lf) || (s.description ?? '').toLowerCase().includes(lf)) : g.signals,
|
||||
})).filter(g => g.signals.length > 0 || !lf);
|
||||
|
||||
return (
|
||||
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Signals</span>}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div class="signal-tree-body">
|
||||
<div class="signal-tree-search">
|
||||
<input
|
||||
class="signal-filter"
|
||||
type="search"
|
||||
placeholder="Filter signals…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="panel-list signal-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading signals…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p class="hint">No signals found.</p>
|
||||
) : (
|
||||
filtered.map(group => (
|
||||
<div key={group.ds} class="signal-group">
|
||||
<div class="signal-group-header" onClick={() => toggleGroup(group.ds)}>
|
||||
<span class="signal-group-arrow">{group.expanded ? '▾' : '▸'}</span>
|
||||
<span class="signal-group-name">{group.ds}</span>
|
||||
<span class="signal-group-count">{group.signals.length}</span>
|
||||
</div>
|
||||
{group.expanded && group.signals.map(sig => (
|
||||
<div
|
||||
key={sig.name}
|
||||
class="signal-item"
|
||||
draggable
|
||||
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
||||
onDragStart={(e: DragEvent) => {
|
||||
const ref: SignalRef = { ds: group.ds, name: sig.name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
}}
|
||||
>
|
||||
<span class="signal-name">{sig.name}</span>
|
||||
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import InterfaceList from './InterfaceList';
|
||||
import Canvas from './Canvas';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import type { Interface } from './lib/types';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
}
|
||||
|
||||
export default function ViewMode({ onEdit }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
setParseError(null);
|
||||
setCurrentInterface(parseInterface(xml));
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : 'Failed to parse interface file.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavigate(interfaceId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const xml = await res.text();
|
||||
handleLoad(xml);
|
||||
} catch (err) {
|
||||
setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="view-mode">
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="app-name">uopi</span>
|
||||
{currentInterface && (
|
||||
<span class="iface-name">{currentInterface.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{parseError && (
|
||||
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class={`status-chip ${wsStatus}`}>
|
||||
<span class="status-dot"></span>
|
||||
{wsStatus}
|
||||
</div>
|
||||
<button
|
||||
class="btn-edit"
|
||||
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
||||
title="Switch to Edit mode"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<InterfaceList
|
||||
onLoad={handleLoad}
|
||||
onEdit={onEdit}
|
||||
onSelect={async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
handleLoad(await res.text());
|
||||
} catch (err) {
|
||||
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Canvas iface={currentInterface} onNavigate={handleNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
onPick: (type: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const WIDGET_TYPES = [
|
||||
{ type: 'textview', label: 'Text View', desc: 'Live value display' },
|
||||
{ type: 'gauge', label: 'Gauge', desc: 'Circular arc gauge' },
|
||||
{ type: 'barh', label: 'Bar (H)', desc: 'Horizontal bar' },
|
||||
{ type: 'barv', label: 'Bar (V)', desc: 'Vertical bar' },
|
||||
{ type: 'led', label: 'LED', desc: 'State indicator' },
|
||||
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
|
||||
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
|
||||
{ type: 'button', label: 'Button', desc: 'Send command on click' },
|
||||
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
|
||||
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
|
||||
{ type: 'image', label: 'Image', desc: 'Image from URL' },
|
||||
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
|
||||
];
|
||||
|
||||
export default function WidgetTypePicker({ x, y, onPick, onCancel }: Props) {
|
||||
return (
|
||||
<div
|
||||
class="widget-picker-backdrop"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
class="widget-picker"
|
||||
style={`left:${x}px;top:${y}px;`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="widget-picker-header">
|
||||
<span>Choose widget type</span>
|
||||
<button class="icon-btn" onClick={onCancel}>✕</button>
|
||||
</div>
|
||||
<div class="widget-picker-list">
|
||||
{WIDGET_TYPES.map(wt => (
|
||||
<button
|
||||
key={wt.type}
|
||||
class="widget-picker-item"
|
||||
onClick={() => onPick(wt.type)}
|
||||
>
|
||||
<span class="wpt-label">{wt.label}</span>
|
||||
<span class="wpt-desc">{wt.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-296
@@ -1,296 +0,0 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,98 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Interface, Widget } from './types';
|
||||
import TextView from './widgets/TextView.svelte';
|
||||
|
||||
let { iface }: { iface: Interface | null } = $props();
|
||||
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
|
||||
function componentForType(type: string): any {
|
||||
switch (type) {
|
||||
case 'textview': return TextView;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="canvas-container"
|
||||
style="--dpr: {dpr};"
|
||||
>
|
||||
{#if iface === null}
|
||||
<div class="placeholder">
|
||||
<p>Select an interface from the left panel, or import one.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="canvas-area">
|
||||
{#each iface.widgets as widget (widget.id)}
|
||||
{#if componentForType(widget.type) !== null}
|
||||
{@const Comp = componentForType(widget.type)}
|
||||
<Comp {widget} />
|
||||
{:else}
|
||||
<!-- Unknown widget type: grey placeholder box -->
|
||||
<div
|
||||
class="unknown-widget"
|
||||
style="
|
||||
left: {widget.x}px;
|
||||
top: {widget.y}px;
|
||||
width: {widget.w}px;
|
||||
height: {widget.h}px;
|
||||
"
|
||||
title="Unknown widget type: {widget.type}"
|
||||
>
|
||||
<span class="unknown-label">{widget.type}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background: #0f1117;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
position: relative;
|
||||
/* Large enough to accommodate absolutely-positioned widgets */
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: max-content;
|
||||
height: max-content;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.unknown-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1e2535;
|
||||
border: 1px dashed #3d4f6e;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.unknown-label {
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script lang="ts">
|
||||
let count: number = $state(0)
|
||||
const increment = () => {
|
||||
count += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" class="counter" onclick={increment}>
|
||||
Count is {count}
|
||||
</button>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Edit mode — coming in Phase 6
|
||||
</script>
|
||||
|
||||
<div class="edit-mode">
|
||||
<p>Edit mode — coming in Phase 6</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.edit-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background: #0f1117;
|
||||
color: #475569;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,214 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface ServerInterface {
|
||||
name: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { onLoad }: { onLoad: (xml: string) => void } = $props();
|
||||
|
||||
let collapsed = $state(false);
|
||||
let interfaces = $state<ServerInterface[]>([]);
|
||||
let loading = $state(true);
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/interfaces');
|
||||
if (resp.ok) {
|
||||
interfaces = await resp.json();
|
||||
}
|
||||
} catch {
|
||||
// Server not reachable; leave empty list
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed = !collapsed;
|
||||
}
|
||||
|
||||
function handleNewInterface() {
|
||||
// Phase 6: edit mode
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function handleFileChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const xml = await file.text();
|
||||
onLoad(xml);
|
||||
} catch (err) {
|
||||
console.error('Failed to read interface file:', err);
|
||||
} finally {
|
||||
// Reset so the same file can be re-imported
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="panel" class:collapsed>
|
||||
<div class="panel-header">
|
||||
{#if !collapsed}
|
||||
<span class="panel-title">Interfaces</span>
|
||||
{/if}
|
||||
<button class="icon-btn" onclick={toggleCollapse} title={collapsed ? 'Expand panel' : 'Collapse panel'}>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !collapsed}
|
||||
<div class="panel-actions">
|
||||
<button class="btn" onclick={handleNewInterface} title="New interface (Edit mode — Phase 6)">
|
||||
+ New
|
||||
</button>
|
||||
<button class="btn" onclick={triggerImport} title="Import interface from XML file">
|
||||
Import
|
||||
</button>
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style="display:none"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel-list">
|
||||
{#if loading}
|
||||
<p class="hint">Loading…</p>
|
||||
{:else if interfaces.length === 0}
|
||||
<p class="hint">No interfaces saved yet. Create one in Edit mode.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each interfaces as iface}
|
||||
<li class="iface-item">{iface.name ?? iface.id ?? 'Unnamed'}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: #1a1f2e;
|
||||
border-right: 1px solid #2d3748;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s ease, min-width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel.collapsed {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
min-height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapsed .panel-header {
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
background: #2d3748;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.75rem 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.iface-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
margin: 1px 4px;
|
||||
}
|
||||
|
||||
.iface-item:hover {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,175 +0,0 @@
|
||||
<script lang="ts">
|
||||
import InterfaceList from './InterfaceList.svelte';
|
||||
import Canvas from './Canvas.svelte';
|
||||
import { wsClient } from './ws';
|
||||
import { parseInterface } from './xml';
|
||||
import type { Interface } from './types';
|
||||
|
||||
let currentInterface = $state<Interface | null>(null);
|
||||
let parseError = $state<string | null>(null);
|
||||
|
||||
const wsStatus = wsClient.status;
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
parseError = null;
|
||||
currentInterface = parseInterface(xml);
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : 'Failed to parse interface file.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="view-mode">
|
||||
<!-- Top toolbar -->
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="app-name">uopi</span>
|
||||
{#if currentInterface}
|
||||
<span class="iface-name">{currentInterface.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{#if parseError}
|
||||
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class="status-chip" class:connected={$wsStatus === 'connected'} class:disconnected={$wsStatus === 'disconnected'} class:connecting={$wsStatus === 'connecting'}>
|
||||
<span class="status-dot"></span>
|
||||
{$wsStatus}
|
||||
</div>
|
||||
<button class="btn-edit" disabled title="Edit mode — coming in Phase 6">Edit</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content row -->
|
||||
<div class="content">
|
||||
<InterfaceList onLoad={handleLoad} />
|
||||
<Canvas iface={currentInterface} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #0f1117;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
height: 44px;
|
||||
background: #1a1f2e;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #60a5fa;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.iface-name {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.parse-error {
|
||||
font-size: 0.78rem;
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-chip.connected {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
border-color: #166534;
|
||||
}
|
||||
|
||||
.status-chip.disconnected {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
.status-chip.connecting {
|
||||
background: #1e293b;
|
||||
color: #fbbf24;
|
||||
border-color: #92400e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #2d3748;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
// Minimal reactive store — Svelte-compatible interface, no Svelte dependency.
|
||||
export interface Readable<T> {
|
||||
subscribe(run: (value: T) => void): () => void;
|
||||
}
|
||||
export interface Writable<T> extends Readable<T> {
|
||||
set(value: T): void;
|
||||
update(fn: (value: T) => T): void;
|
||||
get(): T;
|
||||
}
|
||||
|
||||
export function writable<T>(initial: T): Writable<T> {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
subscribe(fn) { subs.add(fn); fn(value); return () => subs.delete(fn); },
|
||||
set(v) { value = v; subs.forEach(fn => fn(v)); },
|
||||
update(fn) { this.set(fn(value)); },
|
||||
get() { return value; },
|
||||
};
|
||||
}
|
||||
|
||||
export function readable<T>(initial: T, start?: (set: (v: T) => void) => () => void): Readable<T> {
|
||||
const w = writable(initial);
|
||||
if (start) {
|
||||
let stop: (() => void) | null = null;
|
||||
let subscribers = 0;
|
||||
return {
|
||||
subscribe(fn) {
|
||||
subscribers++;
|
||||
if (subscribers === 1) stop = start(w.set.bind(w));
|
||||
const unsub = w.subscribe(fn);
|
||||
return () => {
|
||||
unsub();
|
||||
subscribers--;
|
||||
if (subscribers === 0 && stop) { stop(); stop = null; }
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
return w;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readable, writable, type Readable } from 'svelte/store';
|
||||
import { readable, writable, type Readable } from './store';
|
||||
import { wsClient } from './ws';
|
||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return '---';
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
});
|
||||
|
||||
const fillPercent = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="barh"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
{#if label}
|
||||
<div class="bar-label">{label}</div>
|
||||
{/if}
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style="width: {fillPercent()}%;"></div>
|
||||
</div>
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barh {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #2d3748;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: #4a9eff;
|
||||
border-radius: 3px;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -1,136 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return '---';
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
});
|
||||
|
||||
const fillPercent = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="barv"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
{#if label}
|
||||
<div class="bar-label">{label}</div>
|
||||
{/if}
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
</div>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style="height: {fillPercent()}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barv {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
width: 24px;
|
||||
flex: 1;
|
||||
background: #2d3748;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
background: #4a9eff;
|
||||
border-radius: 3px;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { wsClient } from '../ws';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const label = $derived(widget.options['label'] ?? 'Button');
|
||||
const valueOpt = $derived(widget.options['value'] ?? '1');
|
||||
const confirm = $derived(widget.options['confirm'] === 'true');
|
||||
|
||||
function handleClick() {
|
||||
if (!sigRef) return;
|
||||
// Parse as number if possible, otherwise send as string
|
||||
const parsed = parseFloat(valueOpt);
|
||||
const val = isNaN(parsed) ? valueOpt : parsed;
|
||||
|
||||
const doWrite = () => wsClient.write(sigRef, val);
|
||||
if (confirm) {
|
||||
if (window.confirm(`Send ${label}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="button-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<button class="btn" onclick={handleClick}>{label}</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.button-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1e3a5f;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #2d5a9e;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2563eb;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
background: #1d4ed8;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
</style>
|
||||
@@ -1,187 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
||||
const thresholdLow = $derived(widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null);
|
||||
const thresholdHigh = $derived(widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null);
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return '---';
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
});
|
||||
|
||||
// Arc: -135deg to +135deg (270deg total)
|
||||
const START_DEG = -135;
|
||||
const END_DEG = 135;
|
||||
const TOTAL_DEG = 270;
|
||||
|
||||
const cx = 50;
|
||||
const cy = 54;
|
||||
const r = 38;
|
||||
|
||||
function degToRad(deg: number) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function polarToXY(deg: number, radius: number) {
|
||||
const rad = degToRad(deg);
|
||||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function arcPath(startDeg: number, endDeg: number, radius: number) {
|
||||
const s = polarToXY(startDeg, radius);
|
||||
const e = polarToXY(endDeg, radius);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
|
||||
}
|
||||
|
||||
function valueToDeg(v: number) {
|
||||
const clamped = Math.max(minVal, Math.min(maxVal, v));
|
||||
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
|
||||
return START_DEG + frac * TOTAL_DEG;
|
||||
}
|
||||
|
||||
const fillColor = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null) return '#4a9eff';
|
||||
if (thresholdHigh !== null && v >= thresholdHigh) return '#ef4444';
|
||||
if (thresholdLow !== null && v <= thresholdLow) return '#f59e0b';
|
||||
return '#4a9eff';
|
||||
});
|
||||
|
||||
const needleDeg = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null) return START_DEG;
|
||||
return valueToDeg(v);
|
||||
});
|
||||
|
||||
const fillArcPath = $derived(() => {
|
||||
const nd = needleDeg();
|
||||
if (nd <= START_DEG) return '';
|
||||
return arcPath(START_DEG, nd, r);
|
||||
});
|
||||
|
||||
const bgArcPath = $derived(() => arcPath(START_DEG, END_DEG, r));
|
||||
|
||||
const needlePos = $derived(() => polarToXY(needleDeg(), r - 4));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="gauge"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
<svg viewBox="0 0 100 80" class="gauge-svg">
|
||||
<!-- Background arc -->
|
||||
<path d={bgArcPath()} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
|
||||
<!-- Filled arc -->
|
||||
{#if fillArcPath()}
|
||||
<path d={fillArcPath()} fill="none" stroke={fillColor()} stroke-width="6" stroke-linecap="round" />
|
||||
{/if}
|
||||
<!-- Needle dot -->
|
||||
<circle cx={needlePos().x} cy={needlePos().y} r="3" fill={fillColor()} />
|
||||
<!-- Center value -->
|
||||
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
|
||||
{#if unit}
|
||||
<text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>
|
||||
{/if}
|
||||
<!-- Min/Max labels -->
|
||||
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
|
||||
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
||||
</svg>
|
||||
{#if label}
|
||||
<div class="gauge-label">{label}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gauge {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge-svg {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.gauge-value {
|
||||
fill: #e2e8f0;
|
||||
font-size: 14px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.gauge-unit {
|
||||
fill: #64748b;
|
||||
font-size: 6px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.gauge-range {
|
||||
fill: #475569;
|
||||
font-size: 6px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -1,99 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const condition = $derived(widget.options['condition'] ?? 'value > 0');
|
||||
const colorTrue = $derived(widget.options['colorTrue'] ?? '#22c55e');
|
||||
const colorFalse = $derived(widget.options['colorFalse'] ?? '#ef4444');
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
|
||||
|
||||
function evaluateCondition(expr: string, v: any): boolean {
|
||||
// Safety check: reject dangerous patterns
|
||||
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function('value', 'return ' + expr)(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const ledOn = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return false;
|
||||
return evaluateCondition(condition, v);
|
||||
});
|
||||
|
||||
const ledColor = $derived(() => ledOn() ? colorTrue : colorFalse);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="led-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<div
|
||||
class="led-circle"
|
||||
class:blink={isUncertain}
|
||||
style="background: {ledColor()}; box-shadow: 0 0 8px {ledColor()}88;"
|
||||
></div>
|
||||
{#if label}
|
||||
<div class="led-label">{label}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.led-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.led-circle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.led-circle.blink {
|
||||
animation: blink-slow 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-slow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.led-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,108 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
|
||||
const bits = $derived(parseInt(widget.options['bits'] ?? '8', 10));
|
||||
const labelsRaw = $derived(widget.options['labels'] ?? '');
|
||||
const colorsTrueRaw = $derived(widget.options['colorsTrue'] ?? '');
|
||||
const colorsFalseRaw = $derived(widget.options['colorsFalse'] ?? '');
|
||||
|
||||
const labelArr = $derived(labelsRaw ? labelsRaw.split(',') : []);
|
||||
const colorsTrueArr = $derived(colorsTrueRaw ? colorsTrueRaw.split(',') : []);
|
||||
const colorsFalseArr = $derived(colorsFalseRaw ? colorsFalseRaw.split(',') : []);
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
|
||||
|
||||
const intValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return 0;
|
||||
return typeof v === 'number' ? Math.floor(v) : parseInt(String(v), 10);
|
||||
});
|
||||
|
||||
function getBit(val: number, bit: number): boolean {
|
||||
return (val & (1 << bit)) !== 0;
|
||||
}
|
||||
|
||||
const bitStates = $derived(() => {
|
||||
const val = intValue();
|
||||
return Array.from({ length: bits }, (_, i) => getBit(val, i));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="multiled-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
{#each bitStates() as on, i}
|
||||
{@const trueColor = colorsTrueArr[i] ?? '#22c55e'}
|
||||
{@const falseColor = colorsFalseArr[i] ?? '#ef4444'}
|
||||
{@const color = on ? trueColor : falseColor}
|
||||
{@const bitLabel = labelArr[i] ?? String(i)}
|
||||
<div class="bit-item">
|
||||
<div
|
||||
class="led-circle"
|
||||
class:blink={isUncertain}
|
||||
style="background: {color}; box-shadow: 0 0 6px {color}88;"
|
||||
></div>
|
||||
<div class="bit-label">{bitLabel}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.multiled-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bit-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.led-circle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led-circle.blink {
|
||||
animation: blink-slow 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-slow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.bit-label {
|
||||
font-size: 0.6rem;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,378 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSignalStore } from '../stores';
|
||||
import type { Widget, SignalRef } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const plotType = $derived(widget.options['plotType'] ?? 'timeseries');
|
||||
const timeWindow = $derived(parseFloat(widget.options['timeWindow'] ?? '60'));
|
||||
const legendPos = $derived(widget.options['legend'] ?? 'bottom');
|
||||
const yMinOpt = $derived(widget.options['yMin'] ?? 'auto');
|
||||
const yMaxOpt = $derived(widget.options['yMax'] ?? 'auto');
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let chartEl: HTMLDivElement;
|
||||
|
||||
// ── Shared per-signal data buffers ────────────────────────────────────────────
|
||||
|
||||
interface SeriesBuffer {
|
||||
timestamps: number[]; // Unix seconds
|
||||
values: number[];
|
||||
}
|
||||
|
||||
// Filled on mount — up to 10 signals
|
||||
let buffers: SeriesBuffer[] = [];
|
||||
let unsubs: (() => void)[] = [];
|
||||
|
||||
// ── uPlot (timeseries) ────────────────────────────────────────────────────────
|
||||
|
||||
let uplotInstance: any = null;
|
||||
let uplotImported = false;
|
||||
|
||||
// ── ECharts (other types) ─────────────────────────────────────────────────────
|
||||
|
||||
let echartsInstance: any = null;
|
||||
let echartsImported = false;
|
||||
|
||||
// Histogram per-series: accumulate raw values, bucket on render
|
||||
let histValues: number[][] = [];
|
||||
|
||||
const RING_MAX = 4000;
|
||||
|
||||
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
|
||||
buf.timestamps.push(ts);
|
||||
buf.values.push(val);
|
||||
if (buf.timestamps.length > RING_MAX) {
|
||||
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
|
||||
buf.values.splice(0, buf.values.length - RING_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function windowedSlice(buf: SeriesBuffer, windowSec: number): { ts: number[]; vals: number[] } {
|
||||
const now = Date.now() / 1000;
|
||||
const cutoff = now - windowSec;
|
||||
const start = buf.timestamps.findIndex((t) => t >= cutoff);
|
||||
if (start === -1) return { ts: [], vals: [] };
|
||||
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
||||
}
|
||||
|
||||
// ── uPlot rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
async function initUplot() {
|
||||
if (uplotImported) return;
|
||||
uplotImported = true;
|
||||
|
||||
// @ts-ignore
|
||||
const uPlot = (await import('uplot')).default;
|
||||
|
||||
const signals = widget.signals;
|
||||
const seriesConf = [
|
||||
{},
|
||||
...signals.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: s.color ?? defaultColors[i % defaultColors.length],
|
||||
width: 1.5,
|
||||
})),
|
||||
];
|
||||
|
||||
const legendShow = legendPos !== 'none';
|
||||
|
||||
uplotInstance = new uPlot(
|
||||
{
|
||||
width: chartEl.clientWidth || widget.w - 2,
|
||||
height: chartEl.clientHeight || widget.h - 40,
|
||||
series: seriesConf,
|
||||
legend: { show: legendShow },
|
||||
axes: [
|
||||
{ space: 40 },
|
||||
{
|
||||
min: yMinOpt === 'auto' ? undefined : parseFloat(yMinOpt),
|
||||
max: yMaxOpt === 'auto' ? undefined : parseFloat(yMaxOpt),
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: {
|
||||
range: yMinOpt === 'auto' && yMaxOpt === 'auto'
|
||||
? undefined
|
||||
: [
|
||||
yMinOpt === 'auto' ? null : parseFloat(yMinOpt),
|
||||
yMaxOpt === 'auto' ? null : parseFloat(yMaxOpt),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
buildUplotData(),
|
||||
chartEl,
|
||||
);
|
||||
}
|
||||
|
||||
function buildUplotData(): any[] {
|
||||
if (buffers.length === 0) return [[]];
|
||||
// Use longest timestamps set (first signal)
|
||||
const tw = timeWindow;
|
||||
const allTs = new Set<number>();
|
||||
buffers.forEach((b) => {
|
||||
const { ts } = windowedSlice(b, tw);
|
||||
ts.forEach((t) => allTs.add(t));
|
||||
});
|
||||
const sortedTs = Array.from(allTs).sort((a, b) => a - b);
|
||||
if (sortedTs.length === 0) return [[]];
|
||||
|
||||
const series: any[] = [sortedTs];
|
||||
buffers.forEach((b) => {
|
||||
const tsMap = new Map<number, number>();
|
||||
const { ts, vals } = windowedSlice(b, tw);
|
||||
ts.forEach((t, i) => tsMap.set(t, vals[i]));
|
||||
series.push(sortedTs.map((t) => tsMap.get(t) ?? null));
|
||||
});
|
||||
return series;
|
||||
}
|
||||
|
||||
function updateUplot() {
|
||||
if (!uplotInstance) return;
|
||||
uplotInstance.setData(buildUplotData());
|
||||
}
|
||||
|
||||
// ── ECharts rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
async function initEcharts() {
|
||||
if (echartsImported) return;
|
||||
echartsImported = true;
|
||||
|
||||
// @ts-ignore
|
||||
const echarts = await import('echarts');
|
||||
echartsInstance = echarts.init(chartEl, 'dark');
|
||||
updateEcharts(echarts);
|
||||
}
|
||||
|
||||
function buildEchartsOption(echarts?: any): any {
|
||||
const signals = widget.signals;
|
||||
const legendShow = legendPos !== 'none';
|
||||
|
||||
switch (plotType) {
|
||||
case 'fft': {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'category', name: 'Freq Index', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
|
||||
series: buffers.map((b, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data: b.values.length ? b.values[b.values.length - 1] : [],
|
||||
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
showSymbol: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'histogram': {
|
||||
const buckets = buildHistogram();
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'category', data: buckets.labels, axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
series: buckets.series.map((d, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'bar',
|
||||
data: d,
|
||||
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'bar': {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'category', data: signals.map((s) => s.name), axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: buffers.map((b, i) => ({
|
||||
value: b.values.length ? b.values[b.values.length - 1] : 0,
|
||||
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
})),
|
||||
}],
|
||||
};
|
||||
}
|
||||
case 'logic': {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'value', axisLabel: { color: '#64748b' }, min: 'dataMin', max: 'dataMax' },
|
||||
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLabel: { color: '#64748b' } },
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
step: 'end',
|
||||
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
||||
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'waterfall': {
|
||||
// Display last N rows as a heatmap-style waterfall
|
||||
const ROWS = 32;
|
||||
const history = buffers[0];
|
||||
const rows: number[][] = [];
|
||||
const step = Math.max(1, Math.floor(history.values.length / ROWS));
|
||||
for (let i = 0; i < ROWS; i++) {
|
||||
const idx = Math.min(history.values.length - 1 - (ROWS - 1 - i) * step, history.values.length - 1);
|
||||
if (idx < 0) {
|
||||
rows.push([]);
|
||||
} else {
|
||||
const v = history.values[idx];
|
||||
rows.push(Array.isArray(v) ? v : [v]);
|
||||
}
|
||||
}
|
||||
const flatData: [number, number, number][] = [];
|
||||
rows.forEach((row, ri) => row.forEach((val, ci) => flatData.push([ci, ri, val])));
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
||||
xAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
series: [{ type: 'heatmap', data: flatData }],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildHistogram(): { labels: string[]; series: number[][] } {
|
||||
const BUCKETS = 20;
|
||||
const result: number[][] = buffers.map(() => new Array(BUCKETS).fill(0));
|
||||
let globalMin = Infinity, globalMax = -Infinity;
|
||||
histValues.forEach((vals) => {
|
||||
vals.forEach((v) => {
|
||||
if (v < globalMin) globalMin = v;
|
||||
if (v > globalMax) globalMax = v;
|
||||
});
|
||||
});
|
||||
if (!isFinite(globalMin) || !isFinite(globalMax) || globalMin === globalMax) {
|
||||
return { labels: [], series: result };
|
||||
}
|
||||
const range = globalMax - globalMin;
|
||||
const bucketSize = range / BUCKETS;
|
||||
histValues.forEach((vals, si) => {
|
||||
vals.forEach((v) => {
|
||||
const idx = Math.min(BUCKETS - 1, Math.floor((v - globalMin) / bucketSize));
|
||||
result[si][idx]++;
|
||||
});
|
||||
});
|
||||
const labels = Array.from({ length: BUCKETS }, (_, i) =>
|
||||
(globalMin + i * bucketSize).toPrecision(3),
|
||||
);
|
||||
return { labels, series: result };
|
||||
}
|
||||
|
||||
function updateEcharts(echarts?: any) {
|
||||
if (!echartsInstance) return;
|
||||
echartsInstance.setOption(buildEchartsOption(echarts), { notMerge: true });
|
||||
}
|
||||
|
||||
// ── Mount & cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
const defaultColors = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||
|
||||
onMount(async () => {
|
||||
const signals = widget.signals;
|
||||
buffers = signals.map(() => ({ timestamps: [], values: [] }));
|
||||
histValues = signals.map(() => []);
|
||||
|
||||
signals.forEach((sig: SignalRef & { color?: string }, i: number) => {
|
||||
const store = getSignalStore(sig);
|
||||
const unsub = store.subscribe((sv) => {
|
||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||
const ts = new Date(sv.ts).getTime() / 1000;
|
||||
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
|
||||
if (!isNaN(v)) {
|
||||
pushSample(buffers[i], ts, v);
|
||||
if (plotType === 'histogram') {
|
||||
histValues[i].push(v);
|
||||
if (histValues[i].length > RING_MAX) histValues[i].splice(0, histValues[i].length - RING_MAX);
|
||||
}
|
||||
}
|
||||
// Trigger render
|
||||
if (plotType === 'timeseries') {
|
||||
updateUplot();
|
||||
} else {
|
||||
updateEcharts();
|
||||
}
|
||||
});
|
||||
unsubs.push(unsub);
|
||||
});
|
||||
|
||||
if (plotType === 'timeseries') {
|
||||
await initUplot();
|
||||
} else {
|
||||
await initEcharts();
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (uplotInstance) {
|
||||
uplotInstance.setSize({ width: chartEl.clientWidth, height: chartEl.clientHeight });
|
||||
}
|
||||
if (echartsInstance) {
|
||||
echartsInstance.resize();
|
||||
}
|
||||
});
|
||||
ro.observe(containerEl);
|
||||
unsubs.push(() => ro.disconnect());
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubs.forEach((u) => u());
|
||||
if (uplotInstance) uplotInstance.destroy();
|
||||
if (echartsInstance) echartsInstance.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if plotType === 'timeseries'}
|
||||
<link rel="stylesheet" href="/node_modules/uplot/dist/uPlot.min.css" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="plot-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
bind:this={containerEl}
|
||||
>
|
||||
<div class="chart-area" bind:this={chartEl}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plot-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Ensure uPlot fills the container */
|
||||
.chart-area :global(.uplot) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,170 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import { wsClient } from '../ws';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const confirm = $derived(widget.options['confirm'] === 'true');
|
||||
const isNumeric = $derived(signalMeta ? signalMeta.type !== 'TypeString' : true);
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return '---';
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
let inputValue = $state('');
|
||||
|
||||
function handleSet() {
|
||||
if (!sigRef) return;
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const doWrite = () => {
|
||||
const val = isNumeric ? parseFloat(raw) : raw;
|
||||
wsClient.write(sigRef, val);
|
||||
inputValue = '';
|
||||
};
|
||||
|
||||
if (confirm) {
|
||||
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSet();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="setvalue"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
<span class="sv-label">{label}:</span>
|
||||
{#if isNumeric}
|
||||
<input
|
||||
class="sv-input"
|
||||
type="number"
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="sv-input"
|
||||
type="text"
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
{/if}
|
||||
<span class="sv-current">{displayValue()}</span>
|
||||
{#if unit}<span class="sv-unit">{unit}</span>{/if}
|
||||
<button class="sv-btn" onclick={handleSet}>Set</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setvalue {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 8px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-input {
|
||||
width: 80px;
|
||||
background: #0f1117;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 5px;
|
||||
flex-shrink: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.sv-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.sv-current {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-unit {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-btn {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.sv-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.sv-btn:active {
|
||||
background: #1e40af;
|
||||
}
|
||||
</style>
|
||||
@@ -1,99 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
|
||||
// Reactive stores — re-created when sigRef changes
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
if (!signalValue || signalValue.value === null || signalValue.value === undefined) {
|
||||
return '---';
|
||||
}
|
||||
const v = signalValue.value;
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
const unit = $derived(signalMeta?.unit ?? '');
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="textview"
|
||||
style="
|
||||
left: {widget.x}px;
|
||||
top: {widget.y}px;
|
||||
width: {widget.w}px;
|
||||
height: {widget.h}px;
|
||||
"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
<span class="label">{label}:</span>
|
||||
<span class="value">{displayValue()}</span>
|
||||
{#if unit}
|
||||
<span class="unit">{unit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.textview {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0 0.6em;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #e2e8f0;
|
||||
min-width: 4ch;
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { readable, writable, type Readable } from 'svelte/store';
|
||||
import { readable, writable, type Readable } from './store';
|
||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
+39
-1
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** Serialize an Interface object to an XML string. */
|
||||
export function serializeInterface(iface: Interface): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
lines.push(
|
||||
`<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` +
|
||||
`version="${iface.version}" width="${iface.w}" height="${iface.h}">`
|
||||
);
|
||||
for (const w of iface.widgets) {
|
||||
lines.push(
|
||||
` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` +
|
||||
`x="${w.x}" y="${w.y}" w="${w.w}" h="${w.h}">`
|
||||
);
|
||||
for (const sig of w.signals) {
|
||||
const colorAttr = sig.color ? ` color="${xmlEsc(sig.color)}"` : '';
|
||||
lines.push(` <signal ds="${xmlEsc(sig.ds)}" name="${xmlEsc(sig.name)}"${colorAttr}/>`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(w.options)) {
|
||||
lines.push(` <option key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
|
||||
}
|
||||
lines.push(` </widget>`);
|
||||
}
|
||||
lines.push(`</interface>`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an interface XML string into an Interface object.
|
||||
*
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { mount } from 'svelte'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,5 @@
|
||||
import { h, render, Fragment } from 'preact';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
render(<App />, document.getElementById('app')!);
|
||||
Vendored
+97
@@ -0,0 +1,97 @@
|
||||
// Minimal type declarations for vendored Preact — no npm required.
|
||||
|
||||
// Global JSX namespace — required for "jsx": "react" + jsxFactory: "h"
|
||||
declare namespace JSX {
|
||||
type Element = import('preact').VNode;
|
||||
interface ElementClass { render(): import('preact').ComponentChild; }
|
||||
interface ElementAttributesProperty { props: {}; }
|
||||
interface ElementChildrenAttribute { children: {}; }
|
||||
type IntrinsicElements = {
|
||||
[K in keyof HTMLElementTagNameMap]: any;
|
||||
} & {
|
||||
[K in keyof SVGElementTagNameMap]: any;
|
||||
} & { [key: string]: any };
|
||||
}
|
||||
|
||||
declare module 'preact' {
|
||||
export type Key = string | number;
|
||||
export type Ref<T> = { current: T | null };
|
||||
export type VNode<P = {}> = { type: any; props: P; key: Key | null };
|
||||
export type ComponentType<P = {}> = (props: P) => VNode | null;
|
||||
export type ComponentChild = VNode | string | number | boolean | null | undefined;
|
||||
export type ComponentChildren = ComponentChild | ComponentChild[];
|
||||
|
||||
export interface Attributes {
|
||||
key?: Key;
|
||||
ref?: Ref<any>;
|
||||
children?: ComponentChildren;
|
||||
}
|
||||
|
||||
export function h(type: any, props: any, ...children: any[]): VNode;
|
||||
export const Fragment: unique symbol;
|
||||
export function render(vnode: ComponentChild, parent: Element | ShadowRoot): void;
|
||||
export function cloneElement(vnode: VNode, props?: any, ...children: any[]): VNode;
|
||||
|
||||
namespace JSX {
|
||||
type Element = VNode;
|
||||
interface ElementClass { render(): ComponentChild; }
|
||||
interface ElementAttributesProperty { props: {}; }
|
||||
interface ElementChildrenAttribute { children: {}; }
|
||||
// Allow any HTML/SVG attributes on intrinsic elements
|
||||
type IntrinsicElements = {
|
||||
[K in keyof HTMLElementTagNameMap]: any;
|
||||
} & {
|
||||
[K in keyof SVGElementTagNameMap]: any;
|
||||
} & { [key: string]: any };
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'preact/hooks' {
|
||||
import type { Ref, VNode } from 'preact';
|
||||
|
||||
export type StateUpdater<S> = S | ((prevState: S) => S);
|
||||
export function useState<S>(initialState: S | (() => S)): [S, (update: StateUpdater<S>) => void];
|
||||
export function useReducer<S, A>(reducer: (state: S, action: A) => S, initialState: S): [S, (action: A) => void];
|
||||
export function useEffect(effect: () => void | (() => void), inputs?: readonly any[]): void;
|
||||
export function useLayoutEffect(effect: () => void | (() => void), inputs?: readonly any[]): void;
|
||||
export function useRef<T>(initialValue: T): { current: T };
|
||||
export function useRef<T>(initialValue: T | null): Ref<T>;
|
||||
export function useMemo<T>(factory: () => T, inputs: readonly any[]): T;
|
||||
export function useCallback<T extends (...args: any[]) => any>(callback: T, inputs: readonly any[]): T;
|
||||
export function useContext<T>(context: import('preact').Context<T>): T;
|
||||
}
|
||||
|
||||
declare module 'uplot' {
|
||||
namespace uPlot {
|
||||
type AlignedData = (number | null | undefined)[][];
|
||||
interface Series { label?: string; stroke?: string; width?: number; [k: string]: any; }
|
||||
interface Scale { min?: number; max?: number; time?: boolean; [k: string]: any; }
|
||||
interface Axis { stroke?: string; grid?: any; ticks?: any; [k: string]: any; }
|
||||
interface Options {
|
||||
width: number;
|
||||
height: number;
|
||||
series: Series[];
|
||||
legend?: { show?: boolean };
|
||||
axes?: Axis[];
|
||||
scales?: { [key: string]: Scale };
|
||||
[k: string]: any;
|
||||
}
|
||||
}
|
||||
class uPlot {
|
||||
constructor(opts: uPlot.Options, data: uPlot.AlignedData, target: HTMLElement);
|
||||
setData(data: uPlot.AlignedData): void;
|
||||
setSize(size: { width: number; height: number }): void;
|
||||
destroy(): void;
|
||||
}
|
||||
export default uPlot;
|
||||
}
|
||||
|
||||
declare module 'echarts' {
|
||||
interface EChartsType {
|
||||
setOption(option: any, opts?: any): void;
|
||||
resize(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
export function init(el: HTMLElement, theme?: string | null): EChartsType;
|
||||
export type { EChartsType };
|
||||
}
|
||||
+1312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": false,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "./preact.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function BarH({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
if (rawValue === null || isNaN(rawValue)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="barh"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
{label && <div class="bar-label">{label}</div>}
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style={`width:${fillPercent()}%;`} />
|
||||
</div>
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{unit && <span class="unit">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function BarV({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const quality = sv.quality;
|
||||
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
}
|
||||
|
||||
function fillPercent(): number {
|
||||
if (rawValue === null || isNaN(rawValue)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="barv"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
{label && <div class="bar-label">{label}</div>}
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{unit && <span class="unit">{unit}</span>}
|
||||
</div>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style={`height:${fillPercent()}%;`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { h } from 'preact';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Button({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const label = widget.options['label'] ?? 'Button';
|
||||
const valueOpt = widget.options['value'] ?? '1';
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
|
||||
function handleClick() {
|
||||
if (!sigRef) return;
|
||||
const parsed = parseFloat(valueOpt);
|
||||
const val = isNaN(parsed) ? valueOpt : parsed;
|
||||
const doWrite = () => wsClient.write(sigRef, val);
|
||||
if (confirm) {
|
||||
if (window.confirm(`Send ${label}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="button-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button class="btn" onClick={handleClick}>{label}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
// Arc: -135deg to +135deg (270deg total)
|
||||
const START_DEG = -135;
|
||||
const END_DEG = 135;
|
||||
const TOTAL_DEG = 270;
|
||||
const cx = 50, cy = 54, r = 38;
|
||||
|
||||
function degToRad(deg: number) { return (deg * Math.PI) / 180; }
|
||||
function polarToXY(deg: number, radius: number) {
|
||||
const rad = degToRad(deg);
|
||||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||||
}
|
||||
function arcPath(startDeg: number, endDeg: number, radius: number): string {
|
||||
const s = polarToXY(startDeg, radius);
|
||||
const e = polarToXY(endDeg, radius);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
|
||||
}
|
||||
function valueToDeg(v: number, minVal: number, maxVal: number): number {
|
||||
const clamped = Math.max(minVal, Math.min(maxVal, v));
|
||||
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
|
||||
return START_DEG + frac * TOTAL_DEG;
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Gauge({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
|
||||
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
|
||||
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
|
||||
const thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null;
|
||||
|
||||
const quality = sv.quality;
|
||||
const rawV = sv.value;
|
||||
const rawValue: number | null = rawV === null || rawV === undefined ? null
|
||||
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
|
||||
|
||||
function displayValue(): string {
|
||||
if (rawValue === null || isNaN(rawValue)) return '---';
|
||||
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
|
||||
}
|
||||
|
||||
function fillColor(): string {
|
||||
if (rawValue === null) return '#4a9eff';
|
||||
if (thresholdHigh !== null && rawValue >= thresholdHigh) return '#ef4444';
|
||||
if (thresholdLow !== null && rawValue <= thresholdLow) return '#f59e0b';
|
||||
return '#4a9eff';
|
||||
}
|
||||
|
||||
const needleDeg = rawValue === null ? START_DEG : valueToDeg(rawValue, minVal, maxVal);
|
||||
const bgPath = arcPath(START_DEG, END_DEG, r);
|
||||
const fillPath = needleDeg > START_DEG ? arcPath(START_DEG, needleDeg, r) : '';
|
||||
const needlePos = polarToXY(needleDeg, r - 4);
|
||||
const fc = fillColor();
|
||||
|
||||
return (
|
||||
<div
|
||||
class="gauge"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<svg viewBox="0 0 100 80" class="gauge-svg">
|
||||
<path d={bgPath} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
|
||||
{fillPath && (
|
||||
<path d={fillPath} fill="none" stroke={fc} stroke-width="6" stroke-linecap="round" />
|
||||
)}
|
||||
<circle cx={needlePos.x} cy={needlePos.y} r="3" fill={fc} />
|
||||
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
|
||||
{unit && <text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>}
|
||||
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
|
||||
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
||||
</svg>
|
||||
{label && <div class="gauge-label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { h } from 'preact';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function ImageWidget({ widget, onContextMenu }: Props) {
|
||||
const url = widget.options['url'] ?? '';
|
||||
const fit = widget.options['fit'] ?? 'contain'; // contain | cover | fill
|
||||
const alt = widget.options['alt'] ?? '';
|
||||
const border = widget.options['border'] !== 'false';
|
||||
|
||||
return (
|
||||
<div
|
||||
class="image-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;${border ? '' : 'border:none;'}`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{url
|
||||
? <img src={url} alt={alt} style={`object-fit:${fit};width:100%;height:100%;display:block;`} />
|
||||
: <span class="image-placeholder">No URL</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function evaluateCondition(expr: string, v: any): boolean {
|
||||
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) return false;
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function('value', 'return ' + expr)(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function Led({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsub = getSignalStore(sigRef).subscribe(setSv);
|
||||
return unsub;
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const condition = widget.options['condition'] ?? 'value > 0';
|
||||
const colorTrue = widget.options['colorTrue'] ?? '#22c55e';
|
||||
const colorFalse = widget.options['colorFalse'] ?? '#ef4444';
|
||||
|
||||
const quality = sv.quality;
|
||||
const isUncertain = quality === 'uncertain' || quality === 'unknown';
|
||||
const ledOn = sv.value !== null && sv.value !== undefined ? evaluateCondition(condition, sv.value) : false;
|
||||
const ledColor = ledOn ? colorTrue : colorFalse;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="led-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div
|
||||
class={`led-circle${isUncertain ? ' blink' : ''}`}
|
||||
style={`background:${ledColor};box-shadow:0 0 8px ${ledColor}88;`}
|
||||
/>
|
||||
{label && <div class="led-label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { h } from 'preact';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
widget: Widget;
|
||||
onContextMenu?: (e: MouseEvent) => void;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
}
|
||||
|
||||
export default function LinkWidget({ widget, onContextMenu, onNavigate }: Props) {
|
||||
const target = widget.options['target'] ?? '';
|
||||
const label = widget.options['label'] ?? target ?? 'Open';
|
||||
const icon = widget.options['icon'] ?? '↗';
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (target && onNavigate) onNavigate(target);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="link-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button class="link-btn" onClick={handleClick} title={`Navigate to: ${target}`}>
|
||||
<span class="link-icon">{icon}</span>
|
||||
<span class="link-label">{label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function getBit(val: number, bit: number): boolean {
|
||||
return (val & (1 << bit)) !== 0;
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function MultiLed({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsub = getSignalStore(sigRef).subscribe(setSv);
|
||||
return unsub;
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const bits = parseInt(widget.options['bits'] ?? '8', 10);
|
||||
const labelsRaw = widget.options['labels'] ?? '';
|
||||
const colorsTrueRaw = widget.options['colorsTrue'] ?? '';
|
||||
const colorsFalseRaw = widget.options['colorsFalse'] ?? '';
|
||||
|
||||
const labelArr = labelsRaw ? labelsRaw.split(',') : [];
|
||||
const colorsTrueArr = colorsTrueRaw ? colorsTrueRaw.split(',') : [];
|
||||
const colorsFalseArr = colorsFalseRaw ? colorsFalseRaw.split(',') : [];
|
||||
|
||||
const quality = sv.quality;
|
||||
const isUncertain = quality === 'uncertain' || quality === 'unknown';
|
||||
|
||||
const rawV = sv.value;
|
||||
const intValue = rawV === null || rawV === undefined ? 0
|
||||
: typeof rawV === 'number' ? Math.floor(rawV) : parseInt(String(rawV), 10);
|
||||
|
||||
const bitStates = Array.from({ length: bits }, (_, i) => getBit(intValue, i));
|
||||
|
||||
return (
|
||||
<div
|
||||
class="multiled-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{bitStates.map((on, i) => {
|
||||
const trueColor = colorsTrueArr[i] ?? '#22c55e';
|
||||
const falseColor = colorsFalseArr[i] ?? '#ef4444';
|
||||
const color = on ? trueColor : falseColor;
|
||||
const bitLabel = labelArr[i] ?? String(i);
|
||||
return (
|
||||
<div key={i} class="bit-item">
|
||||
<div
|
||||
class={`led-circle${isUncertain ? ' blink' : ''}`}
|
||||
style={`background:${color};box-shadow:0 0 6px ${color}88;`}
|
||||
/>
|
||||
<div class="bit-label">{bitLabel}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import * as echarts from 'echarts';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalRef } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
interface SeriesBuffer {
|
||||
timestamps: number[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
const RING_MAX = 4000;
|
||||
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||
|
||||
const ECHARTS_DARK = {
|
||||
backgroundColor: '#1a1f2e',
|
||||
textStyle: { color: '#94a3b8' },
|
||||
axisLineStyle: { color: '#475569' },
|
||||
axisLabelStyle: { color: '#64748b' },
|
||||
splitLineStyle: { color: '#2d3748' },
|
||||
};
|
||||
|
||||
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
|
||||
buf.timestamps.push(ts);
|
||||
buf.values.push(val);
|
||||
if (buf.timestamps.length > RING_MAX) {
|
||||
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
|
||||
buf.values.splice(0, buf.values.length - RING_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function windowedSlice(buf: SeriesBuffer, windowSec: number) {
|
||||
const cutoff = Date.now() / 1000 - windowSec;
|
||||
const start = buf.timestamps.findIndex(t => t >= cutoff);
|
||||
if (start === -1) return { ts: [] as number[], vals: [] as number[] };
|
||||
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
||||
}
|
||||
|
||||
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
|
||||
const BUCKETS = 20;
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const vals of allVals) for (const v of vals) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
if (!isFinite(lo) || lo === hi) return { labels: [], counts: allVals.map(() => []) };
|
||||
const size = (hi - lo) / BUCKETS;
|
||||
const counts = allVals.map(vals => {
|
||||
const bins = new Array<number>(BUCKETS).fill(0);
|
||||
for (const v of vals) bins[Math.min(BUCKETS - 1, Math.floor((v - lo) / size))]++;
|
||||
return bins;
|
||||
});
|
||||
const labels = Array.from({ length: BUCKETS }, (_, i) => (lo + i * size).toPrecision(3));
|
||||
return { labels, counts };
|
||||
}
|
||||
|
||||
export default function PlotWidget({ widget, onContextMenu }: Props) {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const signals = widget.signals;
|
||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
|
||||
const yMin = widget.options['yMin'];
|
||||
const yMax = widget.options['yMax'];
|
||||
const legendPos = widget.options['legend'] ?? 'bottom';
|
||||
const showLegend = legendPos !== 'none';
|
||||
|
||||
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
||||
const histValues: number[][] = signals.map(() => []);
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
// ── uPlot (timeseries) ──────────────────────────────────────────────────
|
||||
let uplot: uPlot | null = null;
|
||||
|
||||
function uplotData(): uPlot.AlignedData {
|
||||
if (signals.length === 0) return [[]];
|
||||
const allTs = new Set<number>();
|
||||
buffers.forEach(b => windowedSlice(b, timeWindow).ts.forEach(t => allTs.add(t)));
|
||||
const tsSorted = Array.from(allTs).sort((a, b) => a - b);
|
||||
if (tsSorted.length === 0) {
|
||||
// Return proper empty structure: [timestamps, ...series]
|
||||
return [[], ...signals.map(() => [])] as uPlot.AlignedData;
|
||||
}
|
||||
const tsMap = buffers.map(b => {
|
||||
const m = new Map<number, number>();
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
ts.forEach((t, i) => m.set(t, vals[i]));
|
||||
return m;
|
||||
});
|
||||
return [
|
||||
tsSorted,
|
||||
...tsMap.map(m => tsSorted.map(t => m.get(t) ?? null)),
|
||||
] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// ── ECharts ────────────────────────────────────────────────────────────
|
||||
let echart: echarts.EChartsType | null = null;
|
||||
|
||||
function echartsOption(): any {
|
||||
const axisLine = { lineStyle: { color: '#475569' } };
|
||||
const axisLabel = { color: '#64748b' };
|
||||
const splitLine = { lineStyle: { color: '#2d3748' } };
|
||||
const legendOpt = showLegend
|
||||
? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } }
|
||||
: undefined;
|
||||
|
||||
switch (plotType) {
|
||||
case 'histogram': {
|
||||
const { labels, counts } = buildHistogram(histValues);
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: counts.map((d, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'bar',
|
||||
data: d,
|
||||
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'bar': {
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: buffers.map((b, i) => ({
|
||||
value: b.values.length ? b.values[b.values.length - 1] : 0,
|
||||
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
})),
|
||||
}],
|
||||
};
|
||||
}
|
||||
case 'fft': {
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data: b.values.length ? b.values[b.values.length - 1] : [],
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'logic': {
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line', step: 'end',
|
||||
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'waterfall': {
|
||||
const ROWS = 32;
|
||||
const b = buffers[0];
|
||||
const step = Math.max(1, Math.floor(b.values.length / ROWS));
|
||||
const flatData: [number, number, number][] = [];
|
||||
for (let ri = 0; ri < ROWS; ri++) {
|
||||
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step;
|
||||
if (idx < 0) continue;
|
||||
const row = b.values[idx];
|
||||
const arr = Array.isArray(row) ? row : [row];
|
||||
arr.forEach((val, ci) => flatData.push([ci, ri, val]));
|
||||
}
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
||||
xAxis: { type: 'value', axisLabel },
|
||||
yAxis: { type: 'value', axisLabel },
|
||||
series: [{ type: 'heatmap', data: flatData }],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { backgroundColor: ECHARTS_DARK.backgroundColor };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init chart ─────────────────────────────────────────────────────────
|
||||
const el = chartRef.current;
|
||||
const w = el.clientWidth || widget.w;
|
||||
const h = el.clientHeight || widget.h;
|
||||
|
||||
if (plotType === 'timeseries') {
|
||||
const seriesConf: uPlot.Series[] = [
|
||||
{},
|
||||
...signals.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: s.color ?? COLORS[i % COLORS.length],
|
||||
width: 1.5,
|
||||
})),
|
||||
];
|
||||
|
||||
const scaleY: uPlot.Scale = {};
|
||||
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
|
||||
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
|
||||
|
||||
uplot = new uPlot(
|
||||
{
|
||||
width: w,
|
||||
height: h,
|
||||
series: seriesConf,
|
||||
legend: { show: showLegend },
|
||||
axes: [
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
],
|
||||
scales: { x: { time: true }, y: scaleY },
|
||||
},
|
||||
uplotData(),
|
||||
el,
|
||||
);
|
||||
} else {
|
||||
echart = echarts.init(el);
|
||||
echart.setOption(echartsOption());
|
||||
}
|
||||
|
||||
// ── Subscribe to signals ───────────────────────────────────────────────
|
||||
signals.forEach((sig: SignalRef & { color?: string }, i) => {
|
||||
const unsub = getSignalStore(sig).subscribe(sv => {
|
||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||
const ts = new Date(sv.ts).getTime() / 1000;
|
||||
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
|
||||
if (isNaN(v)) return;
|
||||
|
||||
pushSample(buffers[i], ts, v);
|
||||
if (plotType === 'histogram') histValues[i].push(v);
|
||||
|
||||
if (uplot) uplot.setData(uplotData());
|
||||
else if (echart) echart.setOption(echartsOption(), { notMerge: true });
|
||||
});
|
||||
unsubs.push(unsub);
|
||||
});
|
||||
|
||||
// ── Resize ─────────────────────────────────────────────────────────────
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!chartRef.current) return;
|
||||
const nw = chartRef.current.clientWidth;
|
||||
const nh = chartRef.current.clientHeight;
|
||||
if (nw > 0 && nh > 0) {
|
||||
if (uplot) uplot.setSize({ width: nw, height: nh });
|
||||
if (echart) echart.resize();
|
||||
}
|
||||
});
|
||||
if (outerRef.current) ro.observe(outerRef.current);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
unsubs.forEach(u => u());
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
};
|
||||
}, [widget.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="plot-widget"
|
||||
ref={outerRef}
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const confirm = widget.options['confirm'] === 'true';
|
||||
const isNumeric = meta ? meta.type !== 'TypeString' : true;
|
||||
const quality = sv.quality;
|
||||
|
||||
function displayValue(): string {
|
||||
const v = sv.value;
|
||||
if (v === null || v === undefined) return '---';
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function handleSet() {
|
||||
if (!sigRef) return;
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const doWrite = () => {
|
||||
const val = isNumeric ? parseFloat(raw) : raw;
|
||||
wsClient.write(sigRef, val);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
if (confirm) {
|
||||
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSet();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="setvalue"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<span class="sv-label">{label}:</span>
|
||||
<input
|
||||
class="sv-input"
|
||||
type={isNumeric ? 'number' : 'text'}
|
||||
value={inputValue}
|
||||
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
<span class="sv-current">{displayValue()}</span>
|
||||
{unit && <span class="sv-unit">{unit}</span>}
|
||||
<button class="sv-btn" onClick={handleSet}>Set</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { h } from 'preact';
|
||||
import type { Widget } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function TextLabel({ widget, onContextMenu }: Props) {
|
||||
const text = widget.options['text'] ?? '';
|
||||
const fontSize = widget.options['fontSize'] ?? '1rem';
|
||||
const color = widget.options['color'] ?? '#e2e8f0';
|
||||
const align = widget.options['align'] ?? 'left';
|
||||
|
||||
return (
|
||||
<div
|
||||
class="textlabel"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;font-size:${fontSize};color:${color};text-align:${align};`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function qualityColor(q: string): string {
|
||||
switch (q) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function TextView({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sigRef) return;
|
||||
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||
return () => { unsubV(); unsubM(); };
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
const label = widget.options['label'] ?? sigRef?.name ?? '';
|
||||
const unit = widget.options['unit'] ?? meta?.unit ?? '';
|
||||
const quality = sv.quality;
|
||||
|
||||
function displayValue(): string {
|
||||
if (!sv || sv.value === null || sv.value === undefined) return '---';
|
||||
const v = sv.value;
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="textview"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
|
||||
<span class="label">{label}:</span>
|
||||
<span class="value">{displayValue()}</span>
|
||||
{unit && <span class="unit">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user