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 { MessageService } from 'primeng/api';
import { SchemaStore } from '../../store/schema.store'; import { SchemaStore } from '../../store/schema.store';
import { applyDagreLayout } from '../../features/schema/layout/dagre-layout'; import { applyDagreLayout } from '../../features/schema/layout/dagre-layout';
import { collapseAll, expandAll } from '../../features/schema/collapse/collapse-utils';
@Component({ @Component({
selector: 'app-toolbar', selector: 'app-toolbar',
@ -74,6 +75,9 @@ import { applyDagreLayout } from '../../features/schema/layout/dagre-layout';
(onClick)="store.toggleLasso()" (onClick)="store.toggleLasso()"
/> />
<p-button icon="pi pi-image" size="small" [outlined]="true" pTooltip="Экспорт PNG" tooltipPosition="bottom" (onClick)="wip()" /> <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> </div>
<!-- Right: auto-layout + zoom + mode --> <!-- 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() { wip() {
this.messageService.add({ severity: 'info', summary: 'В разработке' }); this.messageService.add({ severity: 'info', summary: 'В разработке' });
} }

View File

@ -20,6 +20,9 @@ export const CARD_HEIGHT = 40;
export const PORT_RADIUS = 6; export const PORT_RADIUS = 6;
export const CROSS_COLLAPSED_HEIGHT = 40;
export const SPLICE_COLLAPSED_SIZE = 40;
export const LAYER_GAP = 60; export const LAYER_GAP = 60;
export const DEVICE_GAP = 100; export const DEVICE_GAP = 100;
export const LAYER_PADDING_X = 60; 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; id1: string;
id2: string; id2: string;
status: EntityStatus; status: EntityStatus;
collapsed?: boolean;
} }
export function renderCrossDeviceNode(cell: Cell): HTMLElement { export function renderCrossDeviceNode(cell: Cell): HTMLElement {
@ -73,35 +74,52 @@ export function renderCrossDeviceNode(cell: Cell): HTMLElement {
pointerEvents: 'none', 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'); const nameEl = document.createElement('div');
Object.assign(nameEl.style, { Object.assign(nameEl.style, {
fontWeight: '700', fontWeight: '700',
fontSize: '11px', fontSize: '11px',
marginBottom: '4px', marginBottom: data.collapsed ? '0' : '4px',
wordBreak: 'break-word', wordBreak: 'break-word',
}); });
nameEl.textContent = data.name; nameEl.textContent = data.name;
textDiv.appendChild(nameEl); nameRow.appendChild(nameEl);
if (data.networkName) { textDiv.appendChild(nameRow);
const el = document.createElement('div');
Object.assign(el.style, { color: '#595959', fontSize: '9px' });
el.textContent = data.networkName;
textDiv.appendChild(el);
}
if (data.marking) { if (!data.collapsed) {
const el = document.createElement('div'); if (data.networkName) {
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' }); const el = document.createElement('div');
el.textContent = data.marking; Object.assign(el.style, { color: '#595959', fontSize: '9px' });
textDiv.appendChild(el); el.textContent = data.networkName;
} textDiv.appendChild(el);
}
if (data.id1) { if (data.marking) {
const el = document.createElement('div'); const el = document.createElement('div');
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' }); Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' });
el.textContent = data.id1; el.textContent = data.marking;
textDiv.appendChild(el); 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); container.appendChild(textDiv);

View File

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

View File

@ -9,6 +9,7 @@ interface SiteNodeData {
erpCode: string; erpCode: string;
code1C: string; code1C: string;
status: EntityStatus; status: EntityStatus;
collapsed?: boolean;
} }
export function renderSiteNode(cell: Cell): HTMLElement { export function renderSiteNode(cell: Cell): HTMLElement {
@ -44,6 +45,18 @@ export function renderSiteNode(cell: Cell): HTMLElement {
pointerEvents: 'auto', 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'); const nameEl = document.createElement('div');
Object.assign(nameEl.style, { Object.assign(nameEl.style, {
fontWeight: '700', fontWeight: '700',
@ -51,6 +64,7 @@ export function renderSiteNode(cell: Cell): HTMLElement {
wordBreak: 'break-word', wordBreak: 'break-word',
}); });
nameEl.textContent = data.name; nameEl.textContent = data.name;
nameRow.appendChild(nameEl);
const infoEl = document.createElement('div'); const infoEl = document.createElement('div');
Object.assign(infoEl.style, { Object.assign(infoEl.style, {
@ -60,7 +74,7 @@ export function renderSiteNode(cell: Cell): HTMLElement {
}); });
infoEl.textContent = `${data.address} | ERP: ${data.erpCode} | 1С: ${data.code1C}`; infoEl.textContent = `${data.address} | ERP: ${data.erpCode} | 1С: ${data.code1C}`;
header.appendChild(nameEl); header.appendChild(nameRow);
header.appendChild(infoEl); header.appendChild(infoEl);
container.appendChild(header); container.appendChild(header);

View File

@ -9,6 +9,7 @@ interface SpliceNodeData {
id1: string; id1: string;
id2: string; id2: string;
status: EntityStatus; status: EntityStatus;
collapsed?: boolean;
} }
export function renderSpliceNode(cell: Cell): HTMLElement { export function renderSpliceNode(cell: Cell): HTMLElement {
@ -34,17 +35,32 @@ export function renderSpliceNode(cell: Cell): HTMLElement {
lineHeight: '14px', 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'); const nameEl = document.createElement('div');
Object.assign(nameEl.style, { Object.assign(nameEl.style, {
fontWeight: '700', fontWeight: '700',
fontSize: '11px', fontSize: '11px',
marginBottom: '2px', marginBottom: data.collapsed ? '0' : '2px',
wordBreak: 'break-word', wordBreak: 'break-word',
}); });
nameEl.textContent = data.name; 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'); const markingEl = document.createElement('div');
Object.assign(markingEl.style, { color: '#595959', fontSize: '9px' }); Object.assign(markingEl.style, { color: '#595959', fontSize: '9px' });
markingEl.textContent = data.marking; markingEl.textContent = data.marking;

View File

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