diff --git a/frontend/angular.json b/frontend/angular.json index 03ef732..a777f3a 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -58,7 +58,7 @@ "src/styles.css" ], "scripts": [], - "allowedCommonJsDependencies": ["mousetrap"] + "allowedCommonJsDependencies": ["mousetrap", "dagre"] }, "configurations": { "production": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d81a85..00fb0d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,8 @@ "@antv/x6-plugin-snapline": "^2.1.7", "@ngrx/signals": "^19.2.1", "@primeng/themes": "^19.1.4", + "@types/dagre": "^0.7.53", + "dagre": "^0.8.5", "primeicons": "^7.0.0", "primeng": "^19.1.4", "rxjs": "~7.8.0", @@ -5695,6 +5697,12 @@ "@types/node": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.53", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", + "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -7631,6 +7639,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", @@ -8895,6 +8913,15 @@ "dev": true, "license": "ISC" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -10475,7 +10502,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { diff --git a/frontend/package.json b/frontend/package.json index c05487b..a179178 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,8 @@ "@antv/x6-plugin-snapline": "^2.1.7", "@ngrx/signals": "^19.2.1", "@primeng/themes": "^19.1.4", + "@types/dagre": "^0.7.53", + "dagre": "^0.8.5", "primeicons": "^7.0.0", "primeng": "^19.1.4", "rxjs": "~7.8.0", diff --git a/frontend/src/app/components/toolbar/toolbar.component.ts b/frontend/src/app/components/toolbar/toolbar.component.ts index 24f14fb..261144d 100644 --- a/frontend/src/app/components/toolbar/toolbar.component.ts +++ b/frontend/src/app/components/toolbar/toolbar.component.ts @@ -6,6 +6,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { FormsModule } from '@angular/forms'; import { MessageService } from 'primeng/api'; import { SchemaStore } from '../../store/schema.store'; +import { applyDagreLayout } from '../../features/schema/layout/dagre-layout'; @Component({ selector: 'app-toolbar', @@ -62,7 +63,7 @@ import { SchemaStore } from '../../store/schema.store';
- + n.shape === 'site-node'); + const deviceNodes = allNodes.filter( + (n) => n.shape !== 'site-node' && n.shape !== 'card-node', + ); + + if (deviceNodes.length === 0) return; + + // Build dagre graph + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ + rankdir: 'TB', + nodesep: 50, + ranksep: 70, + marginx: 50, + marginy: 50, + }); + + for (const node of deviceNodes) { + const size = node.getSize(); + g.setNode(node.id, { width: size.width, height: size.height }); + } + + // Deduplicate edges at device level + const edgeSet = new Set(); + for (const edge of allEdges) { + const sourceDevice = resolveDeviceId(graph, edge.getSourceCellId()); + const targetDevice = resolveDeviceId(graph, edge.getTargetCellId()); + if (!sourceDevice || !targetDevice) continue; + if (sourceDevice === targetDevice) continue; // skip self-loops + const key = `${sourceDevice}->${targetDevice}`; + if (edgeSet.has(key)) continue; + edgeSet.add(key); + g.setEdge(sourceDevice, targetDevice); + } + + dagre.layout(g); + + // Apply positions inside a batch for single undo step + graph.startBatch('dagre-layout'); + + for (const nodeId of g.nodes()) { + const pos = g.node(nodeId); + const node = graph.getCellById(nodeId) as Node | undefined; + if (node && pos) { + // dagre returns center coordinates, convert to top-left + node.setPosition(pos.x - pos.width / 2, pos.y - pos.height / 2); + } + } + + // Recalculate site containers to wrap their device children + // Process child sites first (those whose devices are inside another site) + // We detect hierarchy by checking which sites share devices + const siteById = new Map(); + for (const site of siteNodes) { + siteById.set(site.id, site); + } + + // Build site → direct children (devices) map via X6 embedding + const siteChildren = new Map(); + for (const site of siteNodes) { + const children = (site.getChildren() ?? []).filter( + (c): c is Node => c.isNode() && c.shape !== 'site-node', + ); + siteChildren.set(site.id, children); + } + + // Detect child sites: a site whose bounding box center is inside another site + // We use the data approach: check if any site is a child of another + // Sort: leaf sites first (those with no child sites inside them) + const childSiteIds = new Set(); + for (const site of siteNodes) { + const children = (site.getChildren() ?? []).filter( + (c): c is Node => c.isNode() && c.shape === 'site-node', + ); + for (const child of children) { + childSiteIds.add(child.id); + } + } + + // First pass: resize child sites (leaf sites) + const sortedSites = [ + ...siteNodes.filter((s) => childSiteIds.has(s.id)), + ...siteNodes.filter((s) => !childSiteIds.has(s.id)), + ]; + + for (const site of sortedSites) { + const children = site.getChildren() ?? []; + const childNodes = children.filter((c): c is Node => c.isNode()); + if (childNodes.length === 0) continue; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const child of childNodes) { + const pos = child.getPosition(); + const size = child.getSize(); + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x + size.width); + maxY = Math.max(maxY, pos.y + size.height); + } + + if (!isFinite(minX)) continue; + + const newX = minX - SITE_PADDING; + const newY = minY - SITE_HEADER_HEIGHT - SITE_PADDING; + const newWidth = maxX - minX + SITE_PADDING * 2; + const newHeight = maxY - minY + SITE_HEADER_HEIGHT + SITE_PADDING * 2; + + site.setPosition(newX, newY); + site.setSize(Math.max(newWidth, 250), Math.max(newHeight, 150)); + } + + graph.stopBatch('dagre-layout'); +}