fix: calculate site bounds from actual device positions
All checks were successful
continuous-integration/drone/push Build is passing
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:
@ -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',
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user