fix: calculate site bounds from actual device positions
All checks were successful
continuous-integration/drone/push Build is passing

Site containers now sized from real device bounding boxes instead
of dagre's reported graph dimensions. Fixes devices overflowing
their site containers after auto-layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-18 10:02:41 +03:00
parent dee964a758
commit 75585a1726

View File

@ -1,5 +1,5 @@
import dagre from 'dagre';
import type { Graph, Node, Cell } from '@antv/x6';
import type { Graph, Node } from '@antv/x6';
import { LAYER_MAPPING } from '../../../constants/layer-mapping';
import {
SITE_PADDING,
@ -31,10 +31,18 @@ interface DeviceEdge {
target: string;
}
interface Bbox {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
const EMPTY_BBOX: Bbox = { minX: 0, minY: 0, maxX: 250, maxY: 150 };
/**
* Layout devices within a single site using dagre.
* Uses LAYER_MAPPING to enforce vertical hierarchy via invisible edges.
* Returns the bounding box size of the laid-out devices.
* Returns actual bounding box of all positioned devices.
*/
function layoutSiteDevices(
graph: Graph,
@ -42,8 +50,10 @@ function layoutSiteDevices(
edges: DeviceEdge[],
offsetX: number,
offsetY: number,
): { width: number; height: number } {
if (devices.length === 0) return { width: 250, height: 150 };
): Bbox {
if (devices.length === 0) {
return { minX: offsetX, minY: offsetY, maxX: offsetX + 250, maxY: offsetY + 150 };
}
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
@ -57,7 +67,7 @@ function layoutSiteDevices(
const deviceIdSet = new Set(devices.map((d) => d.id));
// Group by layer
// Group by layer for hierarchy enforcement
const layerGroups = new Map<number, Node[]>();
for (const device of devices) {
const data = device.getData<{ category?: string }>();
@ -67,13 +77,12 @@ function layoutSiteDevices(
layerGroups.set(layer, group);
}
// Add nodes
for (const device of devices) {
const size = device.getSize();
g.setNode(device.id, { width: size.width, height: size.height });
}
// Add real edges within this site (deduplicated)
// Add real edges within this site
const edgeSet = new Set<string>();
for (const { source, target } of edges) {
if (!deviceIdSet.has(source) || !deviceIdSet.has(target)) continue;
@ -84,7 +93,7 @@ function layoutSiteDevices(
g.setEdge(source, target);
}
// Add invisible edges between consecutive layers to enforce hierarchy
// Add invisible edges between consecutive layers
const sortedLayers = [...layerGroups.keys()].sort((a, b) => a - b);
for (let i = 0; i < sortedLayers.length - 1; i++) {
const upper = layerGroups.get(sortedLayers[i])!;
@ -98,32 +107,49 @@ function layoutSiteDevices(
dagre.layout(g);
// Apply positions with offset (dagre returns center coords)
// Apply positions and track actual bounding box
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const nodeId of g.nodes()) {
if (nodeId.startsWith('_dummy_')) continue;
const pos = g.node(nodeId);
const node = graph.getCellById(nodeId) as Node | undefined;
if (node && pos) {
node.setPosition(
offsetX + pos.x - pos.width / 2,
offsetY + pos.y - pos.height / 2,
);
}
if (!node || !pos) continue;
const x = offsetX + pos.x - pos.width / 2;
const y = offsetY + pos.y - pos.height / 2;
node.setPosition(x, y);
// Track actual bounds including full node size
const size = node.getSize();
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + size.width);
maxY = Math.max(maxY, y + size.height);
}
const graphInfo = g.graph();
return {
width: graphInfo.width ?? 250,
height: graphInfo.height ?? 150,
};
if (!isFinite(minX)) {
return { minX: offsetX, minY: offsetY, maxX: offsetX + 250, maxY: offsetY + 150 };
}
return { minX, minY, maxX, maxY };
}
function getDeviceChildren(siteNode: Node): Node[] {
return (siteNode.getChildren() ?? []).filter(
(c): c is Node =>
c.isNode() && c.shape !== 'site-node' && c.shape !== 'card-node',
);
}
/**
* Applies dagre hierarchical layout to the entire graph.
* - Each site is laid out independently (per-site dagre)
* - Each site is laid out independently
* - LAYER_MAPPING enforces vertical device hierarchy
* - Sites are positioned in a row
* - Site containers are recalculated to wrap their children
* - Site containers are calculated from actual device positions
*/
export function applyDagreLayout(graph: Graph): void {
const allNodes = graph.getNodes();
@ -132,7 +158,7 @@ export function applyDagreLayout(graph: Graph): void {
const siteNodes = allNodes.filter((n) => n.shape === 'site-node');
if (siteNodes.length === 0) return;
// Pre-compute all device-level edges
// Pre-compute device-level edges
const deviceEdges: DeviceEdge[] = [];
const edgeDedup = new Set<string>();
for (const edge of allEdges) {
@ -145,11 +171,10 @@ export function applyDagreLayout(graph: Graph): void {
deviceEdges.push({ source, target });
}
// Detect child sites (embedded in parent site via X6)
// Detect child sites (embedded in parent via X6)
const childSiteIds = new Set<string>();
for (const site of siteNodes) {
const children = site.getChildren() ?? [];
for (const child of children) {
for (const child of site.getChildren() ?? []) {
if (child.isNode() && child.shape === 'site-node') {
childSiteIds.add(child.id);
}
@ -165,79 +190,79 @@ export function applyDagreLayout(graph: Graph): void {
const startY = 50;
for (const rootSite of rootSites) {
// Get direct device children of this root site
const rootDevices = getDeviceChildren(rootSite);
// Layout root site's own devices
const devicesStartY = startY + SITE_HEADER_HEIGHT + SITE_PADDING;
const rootResult = layoutSiteDevices(
// Layout root site devices
const devicesOffsetX = cursorX + SITE_PADDING;
const devicesOffsetY = startY + SITE_HEADER_HEIGHT + SITE_PADDING;
const devicesBbox = layoutSiteDevices(
graph,
rootDevices,
deviceEdges,
cursorX + SITE_PADDING,
devicesStartY,
devicesOffsetX,
devicesOffsetY,
);
// Find child sites of this root site
// Track overall content bbox for this root site
let contentMinX = devicesBbox.minX;
let contentMinY = devicesBbox.minY;
let contentMaxX = devicesBbox.maxX;
let contentMaxY = devicesBbox.maxY;
// Layout child sites below root devices
const childSites = (rootSite.getChildren() ?? []).filter(
(c): c is Node => c.isNode() && c.shape === 'site-node',
);
if (childSites.length > 0) {
let childCursorX = cursorX + SITE_PADDING;
let childSitesMaxHeight = 0;
const childSiteYStart = devicesStartY + rootResult.height + SITE_PADDING;
const childSiteYStart = contentMaxY + SITE_PADDING;
for (const childSite of childSites) {
const childDevices = getDeviceChildren(childSite);
const childDevicesY = childSiteYStart + SITE_HEADER_HEIGHT + SITE_PADDING;
const childDevicesOffsetX = childCursorX + SITE_PADDING;
const childDevicesOffsetY = childSiteYStart + SITE_HEADER_HEIGHT + SITE_PADDING;
const childResult = layoutSiteDevices(
const childBbox = layoutSiteDevices(
graph,
childDevices,
deviceEdges,
childCursorX + SITE_PADDING,
childDevicesY,
childDevicesOffsetX,
childDevicesOffsetY,
);
const childWidth = Math.max(childResult.width + SITE_PADDING * 2, 250);
const childHeight = childResult.height + SITE_HEADER_HEIGHT + SITE_PADDING * 2;
// Size child site to wrap its devices
const childSiteX = childBbox.minX - SITE_PADDING;
const childSiteY = childSiteYStart;
const childSiteW = Math.max(childBbox.maxX - childBbox.minX + SITE_PADDING * 2, 250);
const childSiteH = (childBbox.maxY - childSiteYStart) + SITE_PADDING;
childSite.setPosition(childCursorX, childSiteYStart);
childSite.setSize(childWidth, childHeight);
childSite.setPosition(childSiteX, childSiteY);
childSite.setSize(childSiteW, childSiteH);
childCursorX += childWidth + 40;
childSitesMaxHeight = Math.max(childSitesMaxHeight, childHeight);
// Expand parent content bbox
contentMinX = Math.min(contentMinX, childSiteX);
contentMaxX = Math.max(contentMaxX, childSiteX + childSiteW);
contentMaxY = Math.max(contentMaxY, childSiteY + childSiteH);
childCursorX = childSiteX + childSiteW + 40;
}
}
const childSitesTotalWidth = childCursorX - (cursorX + SITE_PADDING);
const childSitesExtra =
childSites.length > 0 ? childSitesMaxHeight + SITE_PADDING : 0;
// Calculate root site bounds
const rootWidth = Math.max(
rootResult.width + SITE_PADDING * 2,
childSitesTotalWidth,
// Size root site to wrap all content (devices + child sites)
const rootSiteX = Math.min(cursorX, contentMinX - SITE_PADDING);
const rootSiteY = startY;
const rootSiteW = Math.max(
contentMaxX - rootSiteX + SITE_PADDING,
250,
);
const rootHeight =
SITE_HEADER_HEIGHT +
SITE_PADDING +
rootResult.height +
childSitesExtra +
SITE_PADDING;
const rootSiteH = contentMaxY - rootSiteY + SITE_PADDING;
rootSite.setPosition(cursorX, startY);
rootSite.setSize(rootWidth, rootHeight);
rootSite.setPosition(rootSiteX, rootSiteY);
rootSite.setSize(rootSiteW, rootSiteH);
cursorX += rootWidth + siteGap;
cursorX = rootSiteX + rootSiteW + siteGap;
}
graph.stopBatch('dagre-layout');
}
function getDeviceChildren(siteNode: Node): Node[] {
return (siteNode.getChildren() ?? []).filter(
(c): c is Node =>
c.isNode() && c.shape !== 'site-node' && c.shape !== 'card-node',
);
}