diff --git a/frontend/src/app/components/toolbar/toolbar.component.ts b/frontend/src/app/components/toolbar/toolbar.component.ts
index f3ec46c..c56244d 100644
--- a/frontend/src/app/components/toolbar/toolbar.component.ts
+++ b/frontend/src/app/components/toolbar/toolbar.component.ts
@@ -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()"
/>
+
+
+
@@ -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: 'В разработке' });
}
diff --git a/frontend/src/app/constants/sizes.ts b/frontend/src/app/constants/sizes.ts
index d7f97bc..8cae3ef 100644
--- a/frontend/src/app/constants/sizes.ts
+++ b/frontend/src/app/constants/sizes.ts
@@ -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;
diff --git a/frontend/src/app/features/schema/collapse/collapse-utils.ts b/frontend/src/app/features/schema/collapse/collapse-utils.ts
new file mode 100644
index 0000000..310ead5
--- /dev/null
+++ b/frontend/src/app/features/schema/collapse/collapse-utils.ts
@@ -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;
+ 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;
+
+ // 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;
+ 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;
+
+ 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;
+ 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;
+ 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;
+
+ 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;
+ 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;
+
+ 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;
+ 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;
+ 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;
+ 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;
+ 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;
+ if (data?.['collapsed'] !== true) continue;
+ if (node.shape === 'site-node') expandSiteNode(graph, node);
+ }
+
+ for (const node of nodes) {
+ const data = node.getData() as Record;
+ 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);
+ }
+}
diff --git a/frontend/src/app/features/schema/nodes/cross-device-node.renderer.ts b/frontend/src/app/features/schema/nodes/cross-device-node.renderer.ts
index f8ee9dc..e691f21 100644
--- a/frontend/src/app/features/schema/nodes/cross-device-node.renderer.ts
+++ b/frontend/src/app/features/schema/nodes/cross-device-node.renderer.ts
@@ -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);
diff --git a/frontend/src/app/features/schema/nodes/device-node.renderer.ts b/frontend/src/app/features/schema/nodes/device-node.renderer.ts
index f31b269..06d838a 100644
--- a/frontend/src/app/features/schema/nodes/device-node.renderer.ts
+++ b/frontend/src/app/features/schema/nodes/device-node.renderer.ts
@@ -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;
}
diff --git a/frontend/src/app/features/schema/nodes/site-node.renderer.ts b/frontend/src/app/features/schema/nodes/site-node.renderer.ts
index 51cb6de..a7b154a 100644
--- a/frontend/src/app/features/schema/nodes/site-node.renderer.ts
+++ b/frontend/src/app/features/schema/nodes/site-node.renderer.ts
@@ -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);
diff --git a/frontend/src/app/features/schema/nodes/splice-node.renderer.ts b/frontend/src/app/features/schema/nodes/splice-node.renderer.ts
index 143ace3..7ac4728 100644
--- a/frontend/src/app/features/schema/nodes/splice-node.renderer.ts
+++ b/frontend/src/app/features/schema/nodes/splice-node.renderer.ts
@@ -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;
diff --git a/frontend/src/app/features/schema/schema-canvas/schema-canvas.component.ts b/frontend/src/app/features/schema/schema-canvas/schema-canvas.component.ts
index feb2fb0..817d43d 100644
--- a/frontend/src/app/features/schema/schema-canvas/schema-canvas.component.ts
+++ b/frontend/src/app/features/schema/schema-canvas/schema-canvas.component.ts
@@ -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) {