From 75585a1726421dcaa21394a889aca2769b4e26cb Mon Sep 17 00:00:00 2001 From: Alina Date: Wed, 18 Feb 2026 10:02:41 +0300 Subject: [PATCH] fix: calculate site bounds from actual device positions 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 --- .../features/schema/layout/dagre-layout.ts | 181 ++++++++++-------- 1 file changed, 103 insertions(+), 78 deletions(-) diff --git a/frontend/src/app/features/schema/layout/dagre-layout.ts b/frontend/src/app/features/schema/layout/dagre-layout.ts index 352c6a3..670eaba 100644 --- a/frontend/src/app/features/schema/layout/dagre-layout.ts +++ b/frontend/src/app/features/schema/layout/dagre-layout.ts @@ -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(); 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(); 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(); 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(); 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', ); - let childCursorX = cursorX + SITE_PADDING; - let childSitesMaxHeight = 0; - const childSiteYStart = devicesStartY + rootResult.height + SITE_PADDING; + if (childSites.length > 0) { + let childCursorX = cursorX + SITE_PADDING; + const childSiteYStart = contentMaxY + SITE_PADDING; - for (const childSite of childSites) { - const childDevices = getDeviceChildren(childSite); - const childDevicesY = childSiteYStart + SITE_HEADER_HEIGHT + SITE_PADDING; + for (const childSite of childSites) { + const childDevices = getDeviceChildren(childSite); + const childDevicesOffsetX = childCursorX + SITE_PADDING; + const childDevicesOffsetY = childSiteYStart + SITE_HEADER_HEIGHT + SITE_PADDING; - const childResult = layoutSiteDevices( - graph, - childDevices, - deviceEdges, - childCursorX + SITE_PADDING, - childDevicesY, - ); + const childBbox = layoutSiteDevices( + graph, + childDevices, + deviceEdges, + 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', - ); -}