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) {