feat: add dagre auto-layout button
All checks were successful
continuous-integration/drone/push Build is passing

Install dagre for hierarchical graph layout. New toolbar button
applies dagre layout to device nodes based on their connections,
then recalculates site containers to wrap their children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-18 09:50:43 +03:00
parent 71a7d56eb4
commit 91562ccc88
5 changed files with 189 additions and 3 deletions

View File

@ -58,7 +58,7 @@
"src/styles.css" "src/styles.css"
], ],
"scripts": [], "scripts": [],
"allowedCommonJsDependencies": ["mousetrap"] "allowedCommonJsDependencies": ["mousetrap", "dagre"]
}, },
"configurations": { "configurations": {
"production": { "production": {

View File

@ -25,6 +25,8 @@
"@antv/x6-plugin-snapline": "^2.1.7", "@antv/x6-plugin-snapline": "^2.1.7",
"@ngrx/signals": "^19.2.1", "@ngrx/signals": "^19.2.1",
"@primeng/themes": "^19.1.4", "@primeng/themes": "^19.1.4",
"@types/dagre": "^0.7.53",
"dagre": "^0.8.5",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primeng": "^19.1.4", "primeng": "^19.1.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@ -5695,6 +5697,12 @@
"@types/node": "*" "@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": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -7631,6 +7639,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/date-format": {
"version": "4.0.14", "version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
@ -8895,6 +8913,15 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/handle-thing": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
@ -10475,7 +10502,6 @@
"version": "4.17.23", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {

View File

@ -27,6 +27,8 @@
"@antv/x6-plugin-snapline": "^2.1.7", "@antv/x6-plugin-snapline": "^2.1.7",
"@ngrx/signals": "^19.2.1", "@ngrx/signals": "^19.2.1",
"@primeng/themes": "^19.1.4", "@primeng/themes": "^19.1.4",
"@types/dagre": "^0.7.53",
"dagre": "^0.8.5",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primeng": "^19.1.4", "primeng": "^19.1.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",

View File

@ -6,6 +6,7 @@ import { TooltipModule } from 'primeng/tooltip';
import { FormsModule } from '@angular/forms'; 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';
@Component({ @Component({
selector: 'app-toolbar', selector: 'app-toolbar',
@ -62,7 +63,7 @@ import { SchemaStore } from '../../store/schema.store';
<div style="display: flex; align-items: center; gap: 4px"> <div style="display: flex; align-items: center; gap: 4px">
<p-button icon="pi pi-plus" size="small" [outlined]="true" pTooltip="Добавить объект" tooltipPosition="bottom" (onClick)="wip()" /> <p-button icon="pi pi-plus" size="small" [outlined]="true" pTooltip="Добавить объект" tooltipPosition="bottom" (onClick)="wip()" />
<p-button icon="pi pi-trash" size="small" [outlined]="true" pTooltip="Удалить" tooltipPosition="bottom" (onClick)="deleteSelected()" /> <p-button icon="pi pi-trash" size="small" [outlined]="true" pTooltip="Удалить" tooltipPosition="bottom" (onClick)="deleteSelected()" />
<p-button icon="pi pi-refresh" size="small" [outlined]="true" pTooltip="Обновить раскладку" tooltipPosition="bottom" (onClick)="wip()" /> <p-button icon="pi pi-refresh" size="small" [outlined]="true" pTooltip="Авторасстановка (Dagre)" tooltipPosition="bottom" (onClick)="handleAutoLayout()" />
<p-button <p-button
icon="pi pi-objects-column" icon="pi pi-objects-column"
size="small" size="small"
@ -131,6 +132,14 @@ export class ToolbarComponent {
} }
} }
handleAutoLayout() {
const g = this.store.graph();
if (g) {
applyDagreLayout(g);
this.messageService.add({ severity: 'success', summary: 'Раскладка применена' });
}
}
deleteSelected() { deleteSelected() {
const g = this.store.graph(); const g = this.store.graph();
if (g) { if (g) {

View File

@ -0,0 +1,149 @@
import dagre from 'dagre';
import type { Graph, Node } from '@antv/x6';
import { SITE_PADDING, SITE_HEADER_HEIGHT } from '../../../constants/sizes';
/**
* Resolves a cell ID to its top-level device node ID.
* If the cell is a card, returns the parent device ID.
* If the cell is a device, returns its own ID.
* Returns null for site nodes or unknown cells.
*/
function resolveDeviceId(graph: Graph, cellId: string): string | null {
const cell = graph.getCellById(cellId);
if (!cell || !cell.isNode()) return null;
const shape = cell.shape;
if (shape === 'site-node') return null;
if (shape === 'card-node') {
const parent = cell.getParent();
return parent ? parent.id : null;
}
return cell.id;
}
/**
* Applies dagre hierarchical layout to the graph.
* Devices are positioned by dagre based on their connections.
* Site containers are then recalculated to wrap their children.
*/
export function applyDagreLayout(graph: Graph): void {
const allNodes = graph.getNodes();
const allEdges = graph.getEdges();
const siteNodes = allNodes.filter((n) => 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<string>();
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<string, Node>();
for (const site of siteNodes) {
siteById.set(site.id, site);
}
// Build site → direct children (devices) map via X6 embedding
const siteChildren = new Map<string, Node[]>();
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<string>();
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');
}