This commit is contained in:
Martino Ferrari
2026-04-26 11:01:55 +02:00
parent 91b42027c9
commit e83e183673
17 changed files with 1009 additions and 182 deletions
+3 -1
View File
@@ -40,9 +40,10 @@ interface CtxState {
interface Props {
iface: Interface | null;
onNavigate?: (interfaceId: string) => void;
timeRange?: { start: string; end: string } | null;
}
export default function Canvas({ iface, onNavigate }: Props) {
export default function Canvas({ iface, onNavigate, timeRange }: Props) {
const [ctxMenu, setCtxMenu] = useState<CtxState>({
visible: false, x: 0, y: 0, signal: null,
});
@@ -74,6 +75,7 @@ export default function Canvas({ iface, onNavigate }: Props) {
widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate}
timeRange={timeRange}
/>
: (
<div
+276 -68
View File
@@ -1,19 +1,37 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { useState, useEffect, useRef } from 'preact/hooks';
import type { SignalRef } from './lib/types';
import SyntheticWizard from './SyntheticWizard';
interface SignalInfo {
name: string;
type: string;
type?: string;
unit?: string;
description?: string;
writable: boolean;
writable?: boolean;
}
interface DsGroup {
ds: string;
signals: SignalInfo[];
expanded: boolean;
addingSignal: boolean; // show add-PV input
addValue: string;
}
// Custom signals persisted in localStorage
const LS_KEY = 'uopi:custom-signals';
function loadCustom(): Array<{ ds: string; name: string }> {
try {
return JSON.parse(localStorage.getItem(LS_KEY) ?? '[]');
} catch {
return [];
}
}
function saveCustom(signals: Array<{ ds: string; name: string }>) {
localStorage.setItem(LS_KEY, JSON.stringify(signals));
}
interface Props {
@@ -23,105 +41,295 @@ interface Props {
export default function SignalTree({ onDragStart }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [groups, setGroups] = useState<DsGroup[]>([]);
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true);
const [cfAvailable, setCfAvailable] = useState(false);
const [cfSearching, setCfSearching] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const csvRef = useRef<HTMLInputElement>(null);
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);
}
async function loadGroups() {
setLoading(true);
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, addingSignal: false, addValue: '' };
} catch {
return { ds: name, signals: [], expanded: true, addingSignal: false, addValue: '' };
}
})
);
setGroups(loaded);
// Check if Channel Finder is available
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null);
setCfAvailable(cf?.status === 200);
} catch (e) {
console.error('Failed to load signals:', e);
} finally {
setLoading(false);
}
load();
}, []);
}
useEffect(() => { loadGroups(); }, []);
// Merge custom signals into groups
const allGroups = groups.map(g => {
const extra = customSignals.filter(c => c.ds === g.ds);
const existing = new Set(g.signals.map(s => s.name));
const merged = [
...g.signals,
...extra.filter(c => !existing.has(c.name)).map(c => ({ name: c.name, type: 'custom' } as SignalInfo)),
];
return { ...g, signals: merged };
});
// Custom signals with no matching DS group get their own group
const knownDs = new Set(groups.map(g => g.ds));
const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds));
const orphanGroups: DsGroup[] = Object.entries(
orphanCustom.reduce<Record<string, string[]>>((acc, { ds, name }) => {
(acc[ds] ??= []).push(name);
return acc;
}, {})
).map(([ds, names]) => ({
ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)),
expanded: true, addingSignal: false, addValue: '',
}));
const visibleGroups = [...allGroups, ...orphanGroups];
function toggleGroup(ds: string) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
}
function setAdding(ds: string, val: boolean) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g));
}
function setAddValue(ds: string, val: string) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addValue: val } : g));
}
function commitAdd(ds: string, name: string) {
name = name.trim();
if (!name) { setAdding(ds, false); return; }
const next = [...customSignals, { ds, name }];
setCustomSignals(next);
saveCustom(next);
setAdding(ds, false);
}
function removeCustom(ds: string, name: string) {
const next = customSignals.filter(c => !(c.ds === ds && c.name === name));
setCustomSignals(next);
saveCustom(next);
}
// ── Channel Finder search ────────────────────────────────────────────────
async function handleCfSearch() {
if (!filter) return;
setCfSearching(true);
try {
const res = await fetch(`/api/v1/channel-finder?q=${encodeURIComponent(filter)}`);
if (!res.ok) return;
const names: string[] = await res.json();
const next = [
...customSignals,
...names
.filter(n => !customSignals.some(c => c.ds === 'epics' && c.name === n))
.map(n => ({ ds: 'epics', name: n })),
];
setCustomSignals(next);
saveCustom(next);
} finally {
setCfSearching(false);
}
}
// ── CSV import ────────────────────────────────────────────────────────────
async function handleCsvImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const text = await file.text();
const added: Array<{ ds: string; name: string }> = [];
for (const raw of text.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(',').map(p => p.trim());
if (parts.length === 1 && parts[0]) {
// bare PV name → default to epics
added.push({ ds: 'epics', name: parts[0] });
} else if (parts.length >= 2 && parts[0] && parts[1]) {
added.push({ ds: parts[0], name: parts[1] });
}
}
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
const fresh = added.filter(a => !existing.has(`${a.ds}\0${a.name}`));
const next = [...customSignals, ...fresh];
setCustomSignals(next);
saveCustom(next);
}
// ── Filter ────────────────────────────────────────────────────────────────
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);
const filtered = visibleGroups
.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 || g.addingSignal || !lf);
function makeDraggable(ds: string, sig: SignalInfo) {
return {
draggable: true as const,
onDragStart: (e: DragEvent) => {
const ref: SignalRef = { ds, name: sig.name };
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
onDragStart?.(ref);
},
};
}
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'}
>
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
{collapsed ? '▶' : '◀'}
</button>
</div>
{!collapsed && (
<div class="signal-tree-body">
{/* Search / filter bar */}
<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 class="signal-search-row">
<input
class="signal-filter"
type="search"
placeholder="Filter signals…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
/>
{cfAvailable && filter && (
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}>
{cfSearching ? '…' : '🔍'}
</button>
)}
</div>
</div>
{/* Toolbar: New Synthetic | Import CSV | Refresh */}
<div class="signal-tree-toolbar">
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button>
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button>
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}></button>
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
</div>
{/* Signal groups */}
<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>}
filtered.map(group => {
const groupInGroups = groups.find(g => g.ds === group.ds);
return (
<div key={group.ds} class="signal-group">
<div class="signal-group-header">
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}>
{group.expanded ? '▾' : '▸'}
</span>
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}>
{group.ds}
</span>
<span class="signal-group-count">{group.signals.length}</span>
<button
class="icon-btn signal-add-btn"
title={`Add custom signal to ${group.ds}`}
onClick={() => setAdding(group.ds, true)}
>+</button>
</div>
))}
</div>
))
{group.expanded && (
<div>
{group.signals.map(sig => {
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name);
return (
<div
key={sig.name}
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? `${sig.description}` : ''}`}
{...makeDraggable(group.ds, sig)}
>
<span class="signal-name">{sig.name}</span>
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
{isCustom && (
<button
class="icon-btn signal-remove-btn"
title="Remove custom signal"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }}
></button>
)}
</div>
);
})}
{/* Add-signal input row */}
{(groupInGroups?.addingSignal) && (
<div class="signal-add-row">
<input
class="signal-add-input"
placeholder="Signal / PV name…"
autoFocus
value={groupInGroups?.addValue ?? ''}
onInput={(e) => setAddValue(group.ds, (e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAdd(group.ds, (e.target as HTMLInputElement).value);
if (e.key === 'Escape') setAdding(group.ds, false);
}}
/>
<button class="icon-btn" onClick={() => commitAdd(group.ds, groupInGroups?.addValue ?? '')}></button>
<button class="icon-btn" onClick={() => setAdding(group.ds, false)}></button>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
</div>
)}
{showWizard && (
<SyntheticWizard
onClose={() => setShowWizard(false)}
onCreated={loadGroups}
/>
)}
</aside>
);
}
+59 -2
View File
@@ -1,5 +1,5 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { useState, useMemo } from 'preact/hooks';
import InterfaceList from './InterfaceList';
import Canvas from './Canvas';
import { wsClient } from './lib/ws';
@@ -11,11 +11,23 @@ interface Props {
onEdit?: (iface?: Interface) => void;
}
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
function toLocalInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
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');
// Historical time range — null means live
const now = useMemo(() => new Date(), []);
const [startInput, setStartInput] = useState(() => toLocalInput(new Date(now.getTime() - 3600_000)));
const [endInput, setEndInput] = useState(() => toLocalInput(now));
const [timeRange, setTimeRange] = useState<{ start: string; end: string } | null>(null);
useEffect(() => {
const unsub = wsClient.status.subscribe(setWsStatus);
return unsub;
@@ -41,6 +53,19 @@ export default function ViewMode({ onEdit }: Props) {
}
}
function handleLoadHistory() {
if (!startInput || !endInput) return;
const start = new Date(startInput).toISOString();
const end = new Date(endInput).toISOString();
setTimeRange({ start, end });
}
function handleGoLive() {
setTimeRange(null);
}
const isLive = timeRange === null;
return (
<div class="view-mode">
<header class="toolbar">
@@ -51,6 +76,38 @@ export default function ViewMode({ onEdit }: Props) {
)}
</div>
<div class="toolbar-center">
{/* Time range picker */}
<div class="time-nav">
<button
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
title="Switch to live data"
onClick={handleGoLive}
>
Live
</button>
<input
class="time-input"
type="datetime-local"
value={startInput}
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
title="History start"
/>
<span class="time-sep"></span>
<input
class="time-input"
type="datetime-local"
value={endInput}
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
title="History end"
/>
<button
class="toolbar-btn"
title="Load historical data for this time range"
onClick={handleLoadHistory}
>
Load
</button>
</div>
{parseError && (
<span class="parse-error" title={parseError}>Parse error check console</span>
)}
@@ -84,7 +141,7 @@ export default function ViewMode({ onEdit }: Props) {
}
}}
/>
<Canvas iface={currentInterface} onNavigate={handleNavigate} />
<Canvas iface={currentInterface} onNavigate={handleNavigate} timeRange={timeRange} />
</div>
</div>
);
+6
View File
@@ -34,6 +34,12 @@ export interface Widget {
options: Record<string, string>;
}
// A single data point returned from a history request
export interface HistoryPoint {
ts: string;
value: any;
}
// A complete HMI interface definition
export interface Interface {
id: string;
+63 -2
View File
@@ -1,5 +1,5 @@
import { readable, writable, type Readable } from './store';
import type { SignalRef, SignalValue, SignalMeta } from './types';
import { writable, type Readable } from './store';
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -31,6 +31,8 @@ class WsClient {
private refCounts = new Map<string, number>();
// subscriber callbacks per signal
private subscribers = new Map<string, Set<SubscriberEntry>>();
// pending history request callbacks keyed by "ds\0name"
private histCallbacks = new Map<string, Array<(pts: HistoryPoint[]) => void>>();
connect(url: string): void {
this.url = url;
@@ -146,7 +148,25 @@ class WsClient {
for (const s of subs) s.onMeta(meta);
break;
}
case 'history': {
const hcbs = this.histCallbacks.get(key);
if (hcbs) {
const pts: HistoryPoint[] = (msg.points ?? []).map((p: any) => ({
ts: p.ts ?? p.TS,
value: p.value ?? p.Value,
}));
this.histCallbacks.delete(key);
for (const cb of hcbs) cb(pts);
}
break;
}
case 'error':
// Resolve any pending history callbacks with empty data on error
if (key && this.histCallbacks.has(key)) {
const hcbs = this.histCallbacks.get(key)!;
this.histCallbacks.delete(key);
for (const cb of hcbs) cb([]);
}
console.warn('[ws] server error', msg.code, msg.message);
break;
default:
@@ -197,6 +217,47 @@ class WsClient {
};
}
/**
* Request historical data for a signal.
* Resolves with an array of HistoryPoint once the server responds.
* Resolves with [] if the datasource doesn't support history or times out.
*/
history(
ref: SignalRef,
start: string,
end: string,
maxPoints = 5000,
): Promise<HistoryPoint[]> {
return new Promise((resolve) => {
const key = `${ref.ds}\0${ref.name}`;
// Timeout after 15 s — resolves with empty array
const timer = setTimeout(() => {
const cbs = this.histCallbacks.get(key);
if (cbs) {
const idx = cbs.indexOf(wrappedResolve);
if (idx >= 0) cbs.splice(idx, 1);
if (cbs.length === 0) this.histCallbacks.delete(key);
}
resolve([]);
}, 15_000);
const wrappedResolve = (pts: HistoryPoint[]) => {
clearTimeout(timer);
resolve(pts);
};
let cbs = this.histCallbacks.get(key);
if (!cbs) {
cbs = [];
this.histCallbacks.set(key, cbs);
}
cbs.push(wrappedResolve);
this._send({ type: 'history', ds: ref.ds, name: ref.name, start, end, maxPoints });
});
}
write(ref: SignalRef, value: any): void {
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
}
+198 -1
View File
@@ -69,7 +69,11 @@ body {
.toolbar-center {
flex: 1;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-right {
@@ -984,6 +988,54 @@ body {
.toolbar-btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.toolbar-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.toolbar-btn-active {
background: #1e40af;
color: #93c5fd;
border: 1px solid #3b82f6;
}
/* ── Time range / history navigation ──────────────────────────────────────── */
.time-nav {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.time-input {
font-size: 0.75rem;
background: #1a2236;
border: 1px solid #2d3748;
border-radius: 4px;
color: #cbd5e1;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
.time-input:focus { outline: none; border-color: #63b3ed; }
/* Colour-scheme hint for the browser's native date-time picker */
.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); }
.time-sep {
color: #475569;
font-size: 0.8rem;
}
/* Overlay shown inside a plot widget while history is loading or unavailable */
.hist-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
color: #94a3b8;
background: rgba(26, 31, 46, 0.75);
pointer-events: none;
}
.hist-overlay-warn { color: #fbbf24; }
.edit-body {
display: flex;
flex: 1;
@@ -1201,6 +1253,151 @@ body {
.signal-name { font-size: 0.8rem; color: #cbd5e1; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.signal-unit { font-size: 0.7rem; color: #64748b; flex-shrink: 0; }
/* Signal tree toolbar (+ Synthetic, CSV, Refresh) */
.signal-tree-toolbar {
display: flex;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid #2d3748;
flex-wrap: wrap;
}
/* Row containing filter input + CF search button */
.signal-search-row {
display: flex;
gap: 0.25rem;
align-items: center;
}
/* "+" button inside group header */
.signal-add-btn {
margin-left: auto;
font-size: 0.85rem;
opacity: 0;
transition: opacity 0.15s;
}
.signal-group-header:hover .signal-add-btn { opacity: 1; }
/* Inline add-signal row */
.signal-add-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem 0.2rem 1.5rem;
}
.signal-add-input {
flex: 1;
font-size: 0.8rem;
background: #1a2236;
border: 1px solid #4a5568;
border-radius: 4px;
color: #e2e8f0;
padding: 0.15rem 0.4rem;
outline: none;
}
.signal-add-input:focus { border-color: #63b3ed; }
/* Custom (manually added) signal items */
.signal-custom { opacity: 0.85; }
.signal-custom .signal-name { font-style: italic; }
/* Remove button on custom items */
.signal-remove-btn {
margin-left: auto;
font-size: 0.7rem;
opacity: 0;
transition: opacity 0.15s;
flex-shrink: 0;
}
.signal-item:hover .signal-remove-btn { opacity: 1; }
/* ── Synthetic Wizard ─────────────────────────────────────────────────────── */
.wizard-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.wizard {
background: #1e2535;
border: 1px solid #2d3748;
border-radius: 8px;
width: 420px;
max-width: 95vw;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.wizard-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #2d3748;
font-weight: 600;
font-size: 0.95rem;
color: #e2e8f0;
}
.wizard-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wizard-footer {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding: 0.75rem 1rem;
border-top: 1px solid #2d3748;
}
.wizard-section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #64748b;
letter-spacing: 0.06em;
margin-top: 0.5rem;
}
.wizard-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.wizard-field label {
font-size: 0.75rem;
color: #94a3b8;
}
/* Side-by-side fields */
.wizard-field-row {
flex-direction: row;
gap: 0.5rem;
}
.wizard-field-row > .wizard-field { flex: 1; }
.wizard-error {
color: #fc8181;
font-size: 0.8rem;
background: rgba(252,129,129,0.1);
border: 1px solid #fc8181;
border-radius: 4px;
padding: 0.4rem 0.6rem;
}
/* ── Properties Pane ─────────────────────────────────────────────────────── */
.props-panel {
+88 -21
View File
@@ -1,11 +1,16 @@
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import uPlot from 'uplot';
import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
import type { Widget, SignalRef } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
interface Props {
widget: Widget;
onContextMenu?: (e: MouseEvent) => void;
timeRange?: { start: string; end: string } | null;
}
interface SeriesBuffer {
timestamps: number[];
@@ -54,9 +59,14 @@ function buildHistogram(allVals: number[][]): { labels: string[]; counts: number
return { labels, counts };
}
export default function PlotWidget({ widget, onContextMenu }: Props) {
export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) {
const outerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<HTMLDivElement>(null);
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
// Re-render chart when switching between live and historical mode.
// We stringify timeRange so the effect dependency is a stable primitive.
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
useEffect(() => {
if (!chartRef.current) return;
@@ -76,19 +86,22 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
// ── uPlot (timeseries) ──────────────────────────────────────────────────
let uplot: uPlot | null = null;
function uplotData(): uPlot.AlignedData {
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]];
const allTs = new Set<number>();
buffers.forEach(b => windowedSlice(b, timeWindow).ts.forEach(t => allTs.add(t)));
buffers.forEach(b => {
const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps;
pts.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]));
const pts = windowSec > 0 ? windowedSlice(b, windowSec) : { ts: b.timestamps, vals: b.values };
pts.ts.forEach((t, i) => m.set(t, pts.vals[i]));
return m;
});
return [
@@ -202,6 +215,10 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
const w = el.clientWidth || widget.w;
const h = el.clientHeight || widget.h;
const scaleY: uPlot.Scale = {};
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
if (plotType === 'timeseries') {
const seriesConf: uPlot.Series[] = [
{},
@@ -212,10 +229,6 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
})),
];
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,
@@ -236,19 +249,64 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
echart.setOption(echartsOption());
}
// ── Subscribe to signals ───────────────────────────────────────────────
// ── Historical mode (timeseries only) ──────────────────────────────────
if (timeRange && plotType === 'timeseries') {
setHistStatus('loading');
let cancelled = false;
Promise.all(
signals.map((sig: SignalRef & { color?: string }, i) =>
wsClient.history(sig, timeRange.start, timeRange.end, 10_000).then(pts => {
if (cancelled) return;
for (const p of pts) {
const ts = new Date(p.ts).getTime() / 1000;
const v = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
if (!isNaN(v)) {
buffers[i].timestamps.push(ts);
buffers[i].values.push(v);
}
}
}),
),
).then(() => {
if (cancelled) return;
const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0);
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
if (uplot) uplot.setData(uplotData());
}).catch(() => {
if (!cancelled) setHistStatus('error');
});
return () => {
cancelled = true;
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}
// ── Live streaming mode ────────────────────────────────────────────────
setHistStatus('idle');
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 });
if (plotType === 'timeseries') {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return;
pushSample(buffers[i], ts, v);
if (uplot) {
uplot.setData(uplotData(timeWindow));
}
} else {
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 (echart) echart.setOption(echartsOption(), { notMerge: true });
}
});
unsubs.push(unsub);
});
@@ -271,7 +329,7 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}, [widget.id]);
}, [widget.id, timeRangeKey]);
return (
<div
@@ -281,6 +339,15 @@ export default function PlotWidget({ widget, onContextMenu }: Props) {
onContextMenu={onContextMenu}
>
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
{histStatus === 'loading' && (
<div class="hist-overlay">Loading history</div>
)}
{histStatus === 'empty' && (
<div class="hist-overlay hist-overlay-warn">No archive data for this range</div>
)}
{histStatus === 'error' && (
<div class="hist-overlay hist-overlay-warn">Archive unavailable</div>
)}
</div>
);
}