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');
+}