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 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',
|
||||||
);
|
);
|
||||||
|
|
||||||
let childCursorX = cursorX + SITE_PADDING;
|
if (childSites.length > 0) {
|
||||||
let childSitesMaxHeight = 0;
|
let childCursorX = cursorX + SITE_PADDING;
|
||||||
const childSiteYStart = devicesStartY + rootResult.height + SITE_PADDING;
|
const childSiteYStart = contentMaxY + 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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user