feat: add node collapse/expand on double-click and toolbar buttons
All checks were successful
continuous-integration/drone/push Build is passing

Double-click any node to toggle collapse — hides ports, edges, and
children. Toolbar buttons allow collapsing/expanding all nodes at once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-18 10:27:54 +03:00
parent 669180a406
commit 24a0d7240a
8 changed files with 490 additions and 56 deletions

View File

@ -7,6 +7,7 @@ import { FormsModule } from '@angular/forms';
import { MessageService } from 'primeng/api';
import { SchemaStore } from '../../store/schema.store';
import { applyDagreLayout } from '../../features/schema/layout/dagre-layout';
import { collapseAll, expandAll } from '../../features/schema/collapse/collapse-utils';
@Component({
selector: 'app-toolbar',
@ -74,6 +75,9 @@ import { applyDagreLayout } from '../../features/schema/layout/dagre-layout';
(onClick)="store.toggleLasso()"
/>
<p-button icon="pi pi-image" size="small" [outlined]="true" pTooltip="Экспорт PNG" tooltipPosition="bottom" (onClick)="wip()" />
<span style="width: 1px; height: 24px; background: #e0e0e0; margin: 0 4px"></span>
<p-button icon="pi pi-minus-circle" size="small" [outlined]="true" pTooltip="Свернуть всё" tooltipPosition="bottom" (onClick)="handleCollapseAll()" />
<p-button icon="pi pi-plus-circle" size="small" [outlined]="true" pTooltip="Развернуть всё" tooltipPosition="bottom" (onClick)="handleExpandAll()" />
</div>
<!-- Right: auto-layout + zoom + mode -->
@ -158,6 +162,16 @@ export class ToolbarComponent {
}
}
handleCollapseAll() {
const g = this.store.graph();
if (g) collapseAll(g);
}
handleExpandAll() {
const g = this.store.graph();
if (g) expandAll(g);
}
wip() {
this.messageService.add({ severity: 'info', summary: 'В разработке' });
}

View File

@ -20,6 +20,9 @@ export const CARD_HEIGHT = 40;
export const PORT_RADIUS = 6;
export const CROSS_COLLAPSED_HEIGHT = 40;
export const SPLICE_COLLAPSED_SIZE = 40;
export const LAYER_GAP = 60;
export const DEVICE_GAP = 100;
export const LAYER_PADDING_X = 60;

View File

@ -0,0 +1,346 @@
import type { Graph, Node, Edge } from '@antv/x6';
import {
DEVICE_HEADER_HEIGHT,
SITE_HEADER_HEIGHT,
CROSS_COLLAPSED_HEIGHT,
SPLICE_COLLAPSED_SIZE,
CROSS_WIDTH,
} from '../../../constants/sizes';
// --- Helpers ---
function setNodeEdgesVisibility(graph: Graph, node: Node, visible: boolean): void {
const edges = graph.getConnectedEdges(node);
for (const edge of edges) {
if (visible) {
edge.show();
} else {
edge.hide();
}
}
}
function setPortsVisibility(node: Node, visible: boolean): void {
const ports = node.getPorts();
for (const port of ports) {
node.setPortProp(port.id!, 'attrs/circle/r', visible ? 6 : 0);
}
}
function hideChildEdges(graph: Graph, child: Node): void {
const edges = graph.getConnectedEdges(child);
for (const edge of edges) {
edge.hide();
}
// Also handle card children
const grandchildren = child.getChildren() as Node[] | undefined;
if (grandchildren) {
for (const gc of grandchildren) {
const gcEdges = graph.getConnectedEdges(gc);
for (const e of gcEdges) {
e.hide();
}
}
}
}
function showChildEdges(graph: Graph, child: Node): void {
const childData = child.getData() as Record<string, unknown>;
const childCollapsed = childData?.['collapsed'] === true;
// Show edges connected directly to the child (device-level edges)
const edges = graph.getConnectedEdges(child, { deep: false });
for (const edge of edges) {
edge.show();
}
// Show edges for grandchildren (cards/ports) only if child is not collapsed
if (!childCollapsed) {
const grandchildren = child.getChildren() as Node[] | undefined;
if (grandchildren) {
for (const gc of grandchildren) {
const gcEdges = graph.getConnectedEdges(gc);
for (const e of gcEdges) {
e.show();
}
}
}
}
}
// --- Device Node ---
function collapseDeviceNode(graph: Graph, node: Node): void {
const size = node.getSize();
const data = node.getData() as Record<string, unknown>;
// Hide card children
const children = node.getChildren() as Node[] | undefined;
if (children) {
for (const child of children) {
child.hide();
hideChildEdges(graph, child);
}
}
setNodeEdgesVisibility(graph, node, false);
setPortsVisibility(node, false);
node.resize(size.width, DEVICE_HEADER_HEIGHT);
node.setData({
...data,
collapsed: true,
_expandedWidth: size.width,
_expandedHeight: size.height,
});
}
function expandDeviceNode(graph: Graph, node: Node): void {
const data = node.getData() as Record<string, unknown>;
const expandedW = (data['_expandedWidth'] as number) || node.getSize().width;
const expandedH = (data['_expandedHeight'] as number) || node.getSize().height;
node.resize(expandedW, expandedH);
// Show card children
const children = node.getChildren() as Node[] | undefined;
if (children) {
for (const child of children) {
child.show();
showChildEdges(graph, child);
}
}
setNodeEdgesVisibility(graph, node, true);
setPortsVisibility(node, true);
node.setData({
...data,
collapsed: false,
_expandedWidth: undefined,
_expandedHeight: undefined,
});
}
// --- Site Node ---
function collapseSiteNode(graph: Graph, node: Node): void {
const size = node.getSize();
const data = node.getData() as Record<string, unknown>;
const children = node.getChildren() as Node[] | undefined;
if (children) {
for (const child of children) {
child.hide();
hideChildEdges(graph, child);
// Also hide grandchildren (cards inside devices)
const grandchildren = child.getChildren() as Node[] | undefined;
if (grandchildren) {
for (const gc of grandchildren) {
gc.hide();
}
}
}
}
node.resize(size.width, SITE_HEADER_HEIGHT);
node.setData({
...data,
collapsed: true,
_expandedWidth: size.width,
_expandedHeight: size.height,
});
}
function expandSiteNode(graph: Graph, node: Node): void {
const data = node.getData() as Record<string, unknown>;
const expandedW = (data['_expandedWidth'] as number) || node.getSize().width;
const expandedH = (data['_expandedHeight'] as number) || node.getSize().height;
node.resize(expandedW, expandedH);
const children = node.getChildren() as Node[] | undefined;
if (children) {
for (const child of children) {
child.show();
const childData = child.getData() as Record<string, unknown>;
const childCollapsed = childData?.['collapsed'] === true;
if (!childCollapsed) {
// Device is expanded — show its cards and all edges
const grandchildren = child.getChildren() as Node[] | undefined;
if (grandchildren) {
for (const gc of grandchildren) {
gc.show();
}
}
showChildEdges(graph, child);
} else {
// Device remains collapsed — show device-level edges only
const directEdges = graph.getConnectedEdges(child, { deep: false });
for (const edge of directEdges) {
edge.show();
}
}
}
}
node.setData({
...data,
collapsed: false,
_expandedWidth: undefined,
_expandedHeight: undefined,
});
}
// --- Cross Device Node ---
function collapseCrossNode(graph: Graph, node: Node): void {
const size = node.getSize();
const data = node.getData() as Record<string, unknown>;
setNodeEdgesVisibility(graph, node, false);
setPortsVisibility(node, false);
node.resize(CROSS_WIDTH, CROSS_COLLAPSED_HEIGHT);
node.setData({
...data,
collapsed: true,
_expandedWidth: size.width,
_expandedHeight: size.height,
});
}
function expandCrossNode(graph: Graph, node: Node): void {
const data = node.getData() as Record<string, unknown>;
const expandedW = (data['_expandedWidth'] as number) || node.getSize().width;
const expandedH = (data['_expandedHeight'] as number) || node.getSize().height;
node.resize(expandedW, expandedH);
setNodeEdgesVisibility(graph, node, true);
setPortsVisibility(node, true);
node.setData({
...data,
collapsed: false,
_expandedWidth: undefined,
_expandedHeight: undefined,
});
}
// --- Splice Node ---
function collapseSpliceNode(graph: Graph, node: Node): void {
const size = node.getSize();
const data = node.getData() as Record<string, unknown>;
setNodeEdgesVisibility(graph, node, false);
setPortsVisibility(node, false);
node.resize(SPLICE_COLLAPSED_SIZE, SPLICE_COLLAPSED_SIZE);
node.setData({
...data,
collapsed: true,
_expandedWidth: size.width,
_expandedHeight: size.height,
});
}
function expandSpliceNode(graph: Graph, node: Node): void {
const data = node.getData() as Record<string, unknown>;
const expandedW = (data['_expandedWidth'] as number) || node.getSize().width;
const expandedH = (data['_expandedHeight'] as number) || node.getSize().height;
node.resize(expandedW, expandedH);
setNodeEdgesVisibility(graph, node, true);
setPortsVisibility(node, true);
node.setData({
...data,
collapsed: false,
_expandedWidth: undefined,
_expandedHeight: undefined,
});
}
// --- Public API ---
export function toggleNodeCollapse(graph: Graph, node: Node): void {
const data = node.getData() as Record<string, unknown>;
const collapsed = data?.['collapsed'] === true;
const shape = node.shape;
if (collapsed) {
switch (shape) {
case 'device-node':
expandDeviceNode(graph, node);
break;
case 'site-node':
expandSiteNode(graph, node);
break;
case 'cross-device-node':
expandCrossNode(graph, node);
break;
case 'splice-node':
expandSpliceNode(graph, node);
break;
}
} else {
switch (shape) {
case 'device-node':
collapseDeviceNode(graph, node);
break;
case 'site-node':
collapseSiteNode(graph, node);
break;
case 'cross-device-node':
collapseCrossNode(graph, node);
break;
case 'splice-node':
collapseSpliceNode(graph, node);
break;
}
}
}
export function collapseAll(graph: Graph): void {
const nodes = graph.getNodes();
// Collapse devices first (inside sites), then sites
for (const node of nodes) {
const data = node.getData() as Record<string, unknown>;
if (data?.['collapsed'] === true) continue;
const shape = node.shape;
if (shape === 'device-node') collapseDeviceNode(graph, node);
else if (shape === 'cross-device-node') collapseCrossNode(graph, node);
else if (shape === 'splice-node') collapseSpliceNode(graph, node);
}
for (const node of nodes) {
const data = node.getData() as Record<string, unknown>;
if (data?.['collapsed'] === true) continue;
if (node.shape === 'site-node') collapseSiteNode(graph, node);
}
}
export function expandAll(graph: Graph): void {
const nodes = graph.getNodes();
// Expand sites first, then devices
for (const node of nodes) {
const data = node.getData() as Record<string, unknown>;
if (data?.['collapsed'] !== true) continue;
if (node.shape === 'site-node') expandSiteNode(graph, node);
}
for (const node of nodes) {
const data = node.getData() as Record<string, unknown>;
if (data?.['collapsed'] !== true) continue;
const shape = node.shape;
if (shape === 'device-node') expandDeviceNode(graph, node);
else if (shape === 'cross-device-node') expandCrossNode(graph, node);
else if (shape === 'splice-node') expandSpliceNode(graph, node);
}
}

View File

@ -10,6 +10,7 @@ interface CrossDeviceData {
id1: string;
id2: string;
status: EntityStatus;
collapsed?: boolean;
}
export function renderCrossDeviceNode(cell: Cell): HTMLElement {
@ -73,35 +74,52 @@ export function renderCrossDeviceNode(cell: Cell): HTMLElement {
pointerEvents: 'none',
});
const nameRow = document.createElement('div');
Object.assign(nameRow.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
});
const indicator = document.createElement('span');
Object.assign(indicator.style, { fontSize: '9px', lineHeight: '1' });
indicator.textContent = data.collapsed ? '\u25B6' : '\u25BC';
nameRow.appendChild(indicator);
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
fontSize: '11px',
marginBottom: '4px',
marginBottom: data.collapsed ? '0' : '4px',
wordBreak: 'break-word',
});
nameEl.textContent = data.name;
textDiv.appendChild(nameEl);
nameRow.appendChild(nameEl);
if (data.networkName) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#595959', fontSize: '9px' });
el.textContent = data.networkName;
textDiv.appendChild(el);
}
textDiv.appendChild(nameRow);
if (data.marking) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' });
el.textContent = data.marking;
textDiv.appendChild(el);
}
if (!data.collapsed) {
if (data.networkName) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#595959', fontSize: '9px' });
el.textContent = data.networkName;
textDiv.appendChild(el);
}
if (data.id1) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' });
el.textContent = data.id1;
textDiv.appendChild(el);
if (data.marking) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' });
el.textContent = data.marking;
textDiv.appendChild(el);
}
if (data.id1) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' });
el.textContent = data.id1;
textDiv.appendChild(el);
}
}
container.appendChild(textDiv);

View File

@ -12,6 +12,7 @@ interface DeviceNodeData {
id2: string;
group: DeviceGroup;
status: EntityStatus;
collapsed?: boolean;
}
export function renderDeviceNode(cell: Cell): HTMLElement {
@ -51,6 +52,19 @@ export function renderDeviceNode(cell: Cell): HTMLElement {
textAlign: 'center',
});
const headerTop = document.createElement('div');
Object.assign(headerTop.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
});
const indicator = document.createElement('span');
Object.assign(indicator.style, { fontSize: '9px', lineHeight: '1' });
indicator.textContent = data.collapsed ? '\u25B6' : '\u25BC';
headerTop.appendChild(indicator);
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
@ -59,7 +73,9 @@ export function renderDeviceNode(cell: Cell): HTMLElement {
lineHeight: '13px',
});
nameEl.textContent = data.name;
header.appendChild(nameEl);
headerTop.appendChild(nameEl);
header.appendChild(headerTop);
if (isActive) {
if (data.networkName) {
@ -79,41 +95,43 @@ export function renderDeviceNode(cell: Cell): HTMLElement {
container.appendChild(header);
// Body
const body = document.createElement('div');
Object.assign(body.style, {
flex: '1',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
textAlign: 'center',
});
if (!data.collapsed) {
// Body
const body = document.createElement('div');
Object.assign(body.style, {
flex: '1',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
textAlign: 'center',
});
if (isActive) {
if (data.ipAddress) {
const ipEl = document.createElement('div');
Object.assign(ipEl.style, { color: '#555555', fontSize: '9px' });
ipEl.textContent = data.ipAddress;
body.appendChild(ipEl);
}
} else {
if (data.id1) {
const id1El = document.createElement('div');
Object.assign(id1El.style, { color: '#666666', fontSize: '9px' });
id1El.textContent = data.id1;
body.appendChild(id1El);
}
if (data.id2) {
const id2El = document.createElement('div');
Object.assign(id2El.style, { color: '#888888', fontSize: '9px' });
id2El.textContent = data.id2;
body.appendChild(id2El);
if (isActive) {
if (data.ipAddress) {
const ipEl = document.createElement('div');
Object.assign(ipEl.style, { color: '#555555', fontSize: '9px' });
ipEl.textContent = data.ipAddress;
body.appendChild(ipEl);
}
} else {
if (data.id1) {
const id1El = document.createElement('div');
Object.assign(id1El.style, { color: '#666666', fontSize: '9px' });
id1El.textContent = data.id1;
body.appendChild(id1El);
}
if (data.id2) {
const id2El = document.createElement('div');
Object.assign(id2El.style, { color: '#888888', fontSize: '9px' });
id2El.textContent = data.id2;
body.appendChild(id2El);
}
}
container.appendChild(body);
}
container.appendChild(body);
return container;
}

View File

@ -9,6 +9,7 @@ interface SiteNodeData {
erpCode: string;
code1C: string;
status: EntityStatus;
collapsed?: boolean;
}
export function renderSiteNode(cell: Cell): HTMLElement {
@ -44,6 +45,18 @@ export function renderSiteNode(cell: Cell): HTMLElement {
pointerEvents: 'auto',
});
const nameRow = document.createElement('div');
Object.assign(nameRow.style, {
display: 'flex',
alignItems: 'center',
gap: '4px',
});
const indicator = document.createElement('span');
Object.assign(indicator.style, { fontSize: '9px', lineHeight: '1' });
indicator.textContent = data.collapsed ? '\u25B6' : '\u25BC';
nameRow.appendChild(indicator);
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
@ -51,6 +64,7 @@ export function renderSiteNode(cell: Cell): HTMLElement {
wordBreak: 'break-word',
});
nameEl.textContent = data.name;
nameRow.appendChild(nameEl);
const infoEl = document.createElement('div');
Object.assign(infoEl.style, {
@ -60,7 +74,7 @@ export function renderSiteNode(cell: Cell): HTMLElement {
});
infoEl.textContent = `${data.address} | ERP: ${data.erpCode} | 1С: ${data.code1C}`;
header.appendChild(nameEl);
header.appendChild(nameRow);
header.appendChild(infoEl);
container.appendChild(header);

View File

@ -9,6 +9,7 @@ interface SpliceNodeData {
id1: string;
id2: string;
status: EntityStatus;
collapsed?: boolean;
}
export function renderSpliceNode(cell: Cell): HTMLElement {
@ -34,17 +35,32 @@ export function renderSpliceNode(cell: Cell): HTMLElement {
lineHeight: '14px',
});
const nameRow = document.createElement('div');
Object.assign(nameRow.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
});
const indicator = document.createElement('span');
Object.assign(indicator.style, { fontSize: '9px', lineHeight: '1' });
indicator.textContent = data.collapsed ? '\u25B6' : '\u25BC';
nameRow.appendChild(indicator);
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
fontSize: '11px',
marginBottom: '2px',
marginBottom: data.collapsed ? '0' : '2px',
wordBreak: 'break-word',
});
nameEl.textContent = data.name;
container.appendChild(nameEl);
nameRow.appendChild(nameEl);
if (data.marking) {
container.appendChild(nameRow);
if (!data.collapsed && data.marking) {
const markingEl = document.createElement('div');
Object.assign(markingEl.style, { color: '#595959', fontSize: '9px' });
markingEl.textContent = data.marking;

View File

@ -13,6 +13,7 @@ import { registerAllNodes } from '../graph/register-nodes';
import { buildGraphData } from '../helpers/data-mapper';
import { mockData } from '../../../mock/schema-data';
import { DeviceGroup } from '../../../types/index';
import { toggleNodeCollapse } from '../collapse/collapse-utils';
import type { Graph } from '@antv/x6';
let nodesRegistered = false;
@ -233,6 +234,10 @@ export class SchemaCanvasComponent implements AfterViewInit, OnDestroy {
this.store.setRightPanelData(null);
});
graph.on('node:dblclick', ({ node }) => {
toggleNodeCollapse(graph, node);
});
graph.on('edge:dblclick', ({ edge }) => {
const line = mockData.lines.find((l) => l.id === edge.id);
if (line) {