Improved perfs

This commit is contained in:
Martino Ferrari
2026-05-12 10:16:48 +02:00
parent 912ecdd9ed
commit 6ff8fb5c25
14 changed files with 1357 additions and 25 deletions
+327
View File
@@ -0,0 +1,327 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { GroupNode, SignalRef } from './lib/types';
interface Props {
onDragStart?: (sig: SignalRef) => void;
}
// ── Pure tree helpers ──────────────────────────────────────────────────────────
function genId(): string {
return Math.random().toString(36).slice(2, 10);
}
/** Insert a child node at the end of the folder at `path` (empty = root). */
function insertAt(nodes: GroupNode[], path: number[], child: GroupNode): GroupNode[] {
if (path.length === 0) return [...nodes, child];
return nodes.map((n, i) => {
if (i !== path[0] || n.kind !== 'folder') return n;
return { ...n, children: insertAt(n.children, path.slice(1), child) };
});
}
/** Remove the node at `path`. */
function removeAt(nodes: GroupNode[], path: number[]): GroupNode[] {
if (path.length === 1) return nodes.filter((_, i) => i !== path[0]);
return nodes.map((n, i) => {
if (i !== path[0] || n.kind !== 'folder') return n;
return { ...n, children: removeAt(n.children, path.slice(1)) };
});
}
/** Update the folder label at `path`. */
function renameAt(nodes: GroupNode[], path: number[], label: string): GroupNode[] {
if (path.length === 1) {
return nodes.map((n, i) =>
i === path[0] && n.kind === 'folder' ? { ...n, label } : n
);
}
return nodes.map((n, i) => {
if (i !== path[0] || n.kind !== 'folder') return n;
return { ...n, children: renameAt(n.children, path.slice(1), label) };
});
}
// ── Parse signal input ─────────────────────────────────────────────────────────
/** Parse "ds:name" or bare "name" (defaults to epics). */
function parseSignal(input: string): { ds: string; name: string } | null {
const s = input.trim();
if (!s) return null;
const colon = s.indexOf(':');
if (colon > 0) return { ds: s.slice(0, colon), name: s.slice(colon + 1) };
return { ds: 'epics', name: s };
}
// ── Component ──────────────────────────────────────────────────────────────────
export default function GroupsTree({ onDragStart }: Props) {
const [nodes, setNodes] = useState<GroupNode[]>([]);
const [loading, setLoading] = useState(true);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// What the user is currently adding inline (null = nothing)
const [addingAt, setAddingAt] = useState<{ path: number[]; kind: 'signal' | 'folder' } | null>(null);
const [addInput, setAddInput] = useState('');
// Inline folder rename
const [renamingPath, setRenamingPath] = useState<number[] | null>(null);
const [renameInput, setRenameInput] = useState('');
// ── Load / save ─────────────────────────────────────────────────────────────
useEffect(() => {
fetch('/api/v1/groups')
.then(r => r.json())
.then((data: GroupNode[]) => { setNodes(data); setLoading(false); })
.catch(() => setLoading(false));
}, []);
function save(next: GroupNode[]) {
fetch('/api/v1/groups', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
}).catch(e => console.error('groups save failed:', e));
}
function update(next: GroupNode[]) {
setNodes(next);
save(next);
}
// ── Drag ────────────────────────────────────────────────────────────────────
function makeDraggable(ds: string, name: string) {
return {
draggable: true as const,
onDragStart: (e: DragEvent) => {
const ref: SignalRef = { ds, name };
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
onDragStart?.(ref);
},
};
}
// ── Inline add ──────────────────────────────────────────────────────────────
function startAdding(path: number[], kind: 'signal' | 'folder') {
setAddingAt({ path, kind });
setAddInput('');
}
function commitAdd() {
if (!addingAt) return;
const { path, kind } = addingAt;
const val = addInput.trim();
setAddingAt(null);
setAddInput('');
if (!val) return;
if (kind === 'folder') {
const node: GroupNode = { kind: 'folder', id: genId(), label: val, children: [] };
update(insertAt(nodes, path, node));
} else {
const sig = parseSignal(val);
if (!sig) return;
const node: GroupNode = { kind: 'signal', ds: sig.ds, name: sig.name };
update(insertAt(nodes, path, node));
}
}
function cancelAdd() {
setAddingAt(null);
setAddInput('');
}
// ── Rename ──────────────────────────────────────────────────────────────────
function startRename(path: number[], label: string) {
setRenamingPath(path);
setRenameInput(label);
}
function commitRename() {
if (!renamingPath || !renameInput.trim()) { cancelRename(); return; }
update(renameAt(nodes, renamingPath, renameInput.trim()));
setRenamingPath(null);
}
function cancelRename() {
setRenamingPath(null);
setRenameInput('');
}
// ── Collapse ────────────────────────────────────────────────────────────────
function toggleCollapse(id: string) {
setCollapsed(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
// ── Render ──────────────────────────────────────────────────────────────────
function renderAddRow(path: number[], kind: 'signal' | 'folder') {
const isActive = addingAt &&
addingAt.kind === kind &&
JSON.stringify(addingAt.path) === JSON.stringify(path);
if (!isActive) return null;
return (
<div class="group-add-row">
<input
class="group-add-input"
autoFocus
placeholder={kind === 'signal' ? 'ds:name or PV name…' : 'Folder name…'}
value={addInput}
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAdd();
if (e.key === 'Escape') cancelAdd();
}}
/>
<button class="icon-btn" onClick={commitAdd}></button>
<button class="icon-btn" onClick={cancelAdd}></button>
</div>
);
}
function renderNode(node: GroupNode, path: number[]) {
const pathKey = JSON.stringify(path);
if (node.kind === 'signal') {
return (
<div
key={pathKey}
class="group-signal-item"
title={`${node.ds}:${node.name}`}
{...makeDraggable(node.ds, node.name)}
>
<span class="group-signal-ds">{node.ds}</span>
<span class="group-signal-name">{node.name}</span>
<button
class="icon-btn group-node-remove"
title="Remove from group"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); update(removeAt(nodes, path)); }}
></button>
</div>
);
}
// folder node
const isCollapsed = collapsed.has(node.id);
const isRenaming = renamingPath !== null && JSON.stringify(renamingPath) === pathKey;
return (
<div key={pathKey} class="group-folder">
<div class="group-folder-header">
<span
class="group-folder-arrow"
onClick={() => toggleCollapse(node.id)}
>
{isCollapsed ? '▸' : '▾'}
</span>
{isRenaming ? (
<input
class="group-rename-input"
autoFocus
value={renameInput}
onInput={(e) => setRenameInput((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') cancelRename();
}}
onBlur={commitRename}
/>
) : (
<span
class="group-folder-name"
onDblClick={() => startRename(path, node.label)}
title="Double-click to rename"
>
{node.label}
</span>
)}
<span class="group-folder-count">{node.children.length}</span>
<div class="group-folder-actions">
<button
class="icon-btn"
title="Add signal"
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'signal'); }}
>+📶</button>
<button
class="icon-btn"
title="Add subfolder"
onClick={() => { if (!isCollapsed) toggleCollapse(node.id); startAdding(path, 'folder'); }}
>+📁</button>
<button
class="icon-btn group-node-remove"
title="Delete folder"
onClick={() => update(removeAt(nodes, path))}
>🗑</button>
</div>
</div>
{!isCollapsed && (
<div class="group-folder-children">
{node.children.map((child, i) => renderNode(child, [...path, i]))}
{renderAddRow(path, 'signal')}
{renderAddRow(path, 'folder')}
</div>
)}
</div>
);
}
const isAddingRoot = addingAt !== null && addingAt.path.length === 0;
return (
<div class="groups-tree">
{/* Root-level toolbar */}
<div class="groups-toolbar">
<button
class="panel-btn"
title="New root group"
onClick={() => startAdding([], 'folder')}
>
+ Group
</button>
</div>
{/* Inline input for new root folder */}
{isAddingRoot && addingAt?.kind === 'folder' && (
<div class="group-add-row group-add-root">
<input
class="group-add-input"
autoFocus
placeholder="Group name…"
value={addInput}
onInput={(e) => setAddInput((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') commitAdd();
if (e.key === 'Escape') cancelAdd();
}}
/>
<button class="icon-btn" onClick={commitAdd}></button>
<button class="icon-btn" onClick={cancelAdd}></button>
</div>
)}
<div class="panel-list group-list">
{loading ? (
<p class="hint">Loading groups</p>
) : nodes.length === 0 && !isAddingRoot ? (
<p class="hint">No groups yet click "+ Group" to create one.</p>
) : (
nodes.map((node, i) => renderNode(node, [i]))
)}
</div>
</div>
);
}
+26 -1
View File
@@ -1,8 +1,11 @@
import { h } from 'preact';
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import type { SignalRef } from './lib/types';
import SyntheticWizard from './SyntheticWizard';
import SyntheticEditor from './SyntheticEditor';
import GroupsTree from './GroupsTree';
type TreeTab = 'sources' | 'groups';
interface SignalInfo {
name: string;
@@ -41,6 +44,7 @@ interface Props {
}
export default function SignalTree({ onDragStart, width }: Props) {
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
const [collapsed, setCollapsed] = useState(false);
const [groups, setGroups] = useState<DsGroup[]>([]);
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
@@ -222,6 +226,26 @@ export default function SignalTree({ onDragStart, width }: Props) {
{!collapsed && (
<div class="signal-tree-body">
{/* Tab bar: Sources | Groups */}
<div class="signal-tree-tabs">
<button
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
onClick={() => setTreeTab('sources')}
>Sources</button>
<button
class={`stab${treeTab === 'groups' ? ' stab-active' : ''}`}
onClick={() => setTreeTab('groups')}
>Groups</button>
</div>
{/* Groups tab */}
{treeTab === 'groups' && (
<GroupsTree onDragStart={onDragStart} />
)}
{/* Sources tab contents */}
{treeTab === 'sources' && <>
{/* Search / filter bar */}
<div class="signal-tree-search">
<div class="signal-search-row">
@@ -332,6 +356,7 @@ export default function SignalTree({ onDragStart, width }: Props) {
})
)}
</div>
</>}
</div>
)}
+6
View File
@@ -40,6 +40,12 @@ export interface HistoryPoint {
value: any;
}
// A node in the user-defined signal group tree.
// Folders contain children (other folders or signals); signals are leaf nodes.
export type GroupNode =
| { kind: 'folder'; id: string; label: string; children: GroupNode[] }
| { kind: 'signal'; ds: string; name: string };
// A complete HMI interface definition
export interface Interface {
id: string;
+139
View File
@@ -1185,6 +1185,145 @@ body {
min-height: 0;
}
/* ── Signal tree tab bar ──────────────────────────────────────────────────── */
.signal-tree-tabs {
display: flex;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.stab {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #64748b;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0;
text-transform: uppercase;
letter-spacing: 0.04em;
transition: color 0.15s, border-color 0.15s;
}
.stab:hover { color: #94a3b8; }
.stab-active { color: #e2e8f0; border-bottom-color: #4a9eff; }
/* ── Groups tree ──────────────────────────────────────────────────────────── */
.groups-tree {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.groups-toolbar {
display: flex;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.group-list { overflow-y: auto; flex: 1; padding: 0.25rem 0; }
.group-add-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
}
.group-add-root { padding: 0.25rem 0.5rem; }
.group-add-input {
flex: 1;
background: #0f1117;
border: 1px solid #4a9eff;
border-radius: 3px;
color: #e2e8f0;
font-size: 0.78rem;
padding: 2px 6px;
min-width: 0;
}
.group-folder { margin-bottom: 1px; }
.group-folder-header {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.4rem;
cursor: default;
border-radius: 3px;
font-size: 0.8rem;
color: #94a3b8;
min-height: 1.7rem;
}
.group-folder-header:hover { background: #1e293b; color: #cbd5e1; }
.group-folder-header:hover .group-folder-actions { opacity: 1; }
.group-folder-arrow { font-size: 0.65rem; flex-shrink: 0; width: 0.9rem; text-align: center; cursor: pointer; }
.group-folder-name { flex: 1; font-weight: 600; cursor: pointer; user-select: none; }
.group-folder-count {
font-size: 0.65rem;
color: #475569;
background: #1e293b;
border-radius: 8px;
padding: 0 5px;
min-width: 1.1rem;
text-align: center;
}
.group-folder-actions {
display: flex;
gap: 1px;
opacity: 0;
transition: opacity 0.15s;
}
.group-folder-actions .icon-btn { font-size: 0.65rem; padding: 1px 3px; }
.group-rename-input {
flex: 1;
background: #0f1117;
border: 1px solid #4a9eff;
border-radius: 3px;
color: #e2e8f0;
font-size: 0.8rem;
font-weight: 600;
padding: 1px 4px;
min-width: 0;
}
.group-folder-children {
padding-left: 1rem;
border-left: 1px solid #1e293b;
margin-left: 0.7rem;
}
.group-signal-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.4rem;
font-size: 0.78rem;
color: #94a3b8;
border-radius: 3px;
cursor: grab;
min-height: 1.5rem;
}
.group-signal-item:hover { background: #1e293b; color: #cbd5e1; }
.group-signal-item:hover .group-node-remove { opacity: 1; }
.group-signal-ds {
color: #475569;
font-size: 0.7rem;
flex-shrink: 0;
}
.group-signal-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.group-node-remove { opacity: 0; transition: opacity 0.15s; margin-left: auto; flex-shrink: 0; }
.signal-tree-search {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #2d3748;