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