fix: per-site dagre layout with layer hierarchy
All checks were successful
continuous-integration/drone/push Build is passing

Rewrite dagre auto-layout to work per-site instead of globally.
Each site's devices are laid out independently with LAYER_MAPPING
enforcing vertical hierarchy via invisible edges. Sites positioned
in a row with child sites inside parents. Add X6 embedding for
child sites so layout can detect parent-child site relationships.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-18 09:59:30 +03:00
parent 07e8d2d052
commit dee964a758
2 changed files with 188 additions and 93 deletions

View File

@ -96,6 +96,7 @@ export function buildGraphData(
entityType: 'site',
entityId: site.id,
},
parent: site.parentSiteId ?? undefined,
zIndex: 1,
});
}

View File

@ -1,149 +1,243 @@
import dagre from 'dagre';
import type { Graph, Node } from '@antv/x6';
import { SITE_PADDING, SITE_HEADER_HEIGHT } from '../../../constants/sizes';
import type { Graph, Node, Cell } from '@antv/x6';
import { LAYER_MAPPING } from '../../../constants/layer-mapping';
import {
SITE_PADDING,
SITE_HEADER_HEIGHT,
} from '../../../constants/sizes';
function getLayerForCategory(category: string): number {
for (const [layer, categories] of Object.entries(LAYER_MAPPING)) {
if ((categories as string[]).includes(category)) {
return parseInt(layer, 10);
}
}
return 7;
}
/**
* Resolves a cell ID to its top-level device node ID.
* If the cell is a card, returns the parent device ID.
* If the cell is a device, returns its own ID.
* Returns null for site nodes or unknown cells.
*/
function resolveDeviceId(graph: Graph, cellId: string): string | null {
const cell = graph.getCellById(cellId);
if (!cell || !cell.isNode()) return null;
const shape = cell.shape;
if (shape === 'site-node') return null;
if (shape === 'card-node') {
const parent = cell.getParent();
if (cell.shape === 'site-node') return null;
if (cell.shape === 'card-node') {
const parent = (cell as Node).getParent();
return parent ? parent.id : null;
}
return cell.id;
}
interface DeviceEdge {
source: string;
target: string;
}
/**
* Applies dagre hierarchical layout to the graph.
* Devices are positioned by dagre based on their connections.
* Site containers are then recalculated to wrap their children.
* 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.
*/
export function applyDagreLayout(graph: Graph): void {
const allNodes = graph.getNodes();
const allEdges = graph.getEdges();
function layoutSiteDevices(
graph: Graph,
devices: Node[],
edges: DeviceEdge[],
offsetX: number,
offsetY: number,
): { width: number; height: number } {
if (devices.length === 0) return { width: 250, height: 150 };
const siteNodes = allNodes.filter((n) => n.shape === 'site-node');
const deviceNodes = allNodes.filter(
(n) => n.shape !== 'site-node' && n.shape !== 'card-node',
);
if (deviceNodes.length === 0) return;
// Build dagre graph
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: 'TB',
nodesep: 50,
ranksep: 70,
marginx: 50,
marginy: 50,
marginx: 20,
marginy: 20,
});
for (const node of deviceNodes) {
const size = node.getSize();
g.setNode(node.id, { width: size.width, height: size.height });
const deviceIdSet = new Set(devices.map((d) => d.id));
// Group by layer
const layerGroups = new Map<number, Node[]>();
for (const device of devices) {
const data = device.getData<{ category?: string }>();
const layer = getLayerForCategory(data?.category ?? '');
const group = layerGroups.get(layer) ?? [];
group.push(device);
layerGroups.set(layer, group);
}
// Deduplicate edges at device level
// 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)
const edgeSet = new Set<string>();
for (const edge of allEdges) {
const sourceDevice = resolveDeviceId(graph, edge.getSourceCellId());
const targetDevice = resolveDeviceId(graph, edge.getTargetCellId());
if (!sourceDevice || !targetDevice) continue;
if (sourceDevice === targetDevice) continue; // skip self-loops
const key = `${sourceDevice}->${targetDevice}`;
for (const { source, target } of edges) {
if (!deviceIdSet.has(source) || !deviceIdSet.has(target)) continue;
if (source === target) continue;
const key = `${source}->${target}`;
if (edgeSet.has(key)) continue;
edgeSet.add(key);
g.setEdge(sourceDevice, targetDevice);
g.setEdge(source, target);
}
// Add invisible edges between consecutive layers to enforce hierarchy
const sortedLayers = [...layerGroups.keys()].sort((a, b) => a - b);
for (let i = 0; i < sortedLayers.length - 1; i++) {
const upper = layerGroups.get(sortedLayers[i])!;
const lower = layerGroups.get(sortedLayers[i + 1])!;
const key = `${upper[0].id}->${lower[0].id}`;
if (!edgeSet.has(key)) {
edgeSet.add(key);
g.setEdge(upper[0].id, lower[0].id, { weight: 0, minlen: 1 });
}
}
dagre.layout(g);
// Apply positions inside a batch for single undo step
graph.startBatch('dagre-layout');
// Apply positions with offset (dagre returns center coords)
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) {
// dagre returns center coordinates, convert to top-left
node.setPosition(pos.x - pos.width / 2, pos.y - pos.height / 2);
node.setPosition(
offsetX + pos.x - pos.width / 2,
offsetY + pos.y - pos.height / 2,
);
}
}
// Recalculate site containers to wrap their device children
// Process child sites first (those whose devices are inside another site)
// We detect hierarchy by checking which sites share devices
const siteById = new Map<string, Node>();
for (const site of siteNodes) {
siteById.set(site.id, site);
const graphInfo = g.graph();
return {
width: graphInfo.width ?? 250,
height: graphInfo.height ?? 150,
};
}
/**
* Applies dagre hierarchical layout to the entire graph.
* - Each site is laid out independently (per-site dagre)
* - LAYER_MAPPING enforces vertical device hierarchy
* - Sites are positioned in a row
* - Site containers are recalculated to wrap their children
*/
export function applyDagreLayout(graph: Graph): void {
const allNodes = graph.getNodes();
const allEdges = graph.getEdges();
const siteNodes = allNodes.filter((n) => n.shape === 'site-node');
if (siteNodes.length === 0) return;
// Pre-compute all device-level edges
const deviceEdges: DeviceEdge[] = [];
const edgeDedup = new Set<string>();
for (const edge of allEdges) {
const source = resolveDeviceId(graph, edge.getSourceCellId());
const target = resolveDeviceId(graph, edge.getTargetCellId());
if (!source || !target || source === target) continue;
const key = `${source}->${target}`;
if (edgeDedup.has(key)) continue;
edgeDedup.add(key);
deviceEdges.push({ source, target });
}
// Build site → direct children (devices) map via X6 embedding
const siteChildren = new Map<string, Node[]>();
for (const site of siteNodes) {
const children = (site.getChildren() ?? []).filter(
(c): c is Node => c.isNode() && c.shape !== 'site-node',
);
siteChildren.set(site.id, children);
}
// Detect child sites: a site whose bounding box center is inside another site
// We use the data approach: check if any site is a child of another
// Sort: leaf sites first (those with no child sites inside them)
// Detect child sites (embedded in parent site via X6)
const childSiteIds = new Set<string>();
for (const site of siteNodes) {
const children = (site.getChildren() ?? []).filter(
(c): c is Node => c.isNode() && c.shape === 'site-node',
);
const children = site.getChildren() ?? [];
for (const child of children) {
childSiteIds.add(child.id);
if (child.isNode() && child.shape === 'site-node') {
childSiteIds.add(child.id);
}
}
}
// First pass: resize child sites (leaf sites)
const sortedSites = [
...siteNodes.filter((s) => childSiteIds.has(s.id)),
...siteNodes.filter((s) => !childSiteIds.has(s.id)),
];
const rootSites = siteNodes.filter((s) => !childSiteIds.has(s.id));
for (const site of sortedSites) {
const children = site.getChildren() ?? [];
const childNodes = children.filter((c): c is Node => c.isNode());
if (childNodes.length === 0) continue;
graph.startBatch('dagre-layout');
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let cursorX = 50;
const siteGap = 120;
const startY = 50;
for (const child of childNodes) {
const pos = child.getPosition();
const size = child.getSize();
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + size.width);
maxY = Math.max(maxY, pos.y + size.height);
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(
graph,
rootDevices,
deviceEdges,
cursorX + SITE_PADDING,
devicesStartY,
);
// Find child sites of this root site
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;
for (const childSite of childSites) {
const childDevices = getDeviceChildren(childSite);
const childDevicesY = childSiteYStart + SITE_HEADER_HEIGHT + SITE_PADDING;
const childResult = layoutSiteDevices(
graph,
childDevices,
deviceEdges,
childCursorX + SITE_PADDING,
childDevicesY,
);
const childWidth = Math.max(childResult.width + SITE_PADDING * 2, 250);
const childHeight = childResult.height + SITE_HEADER_HEIGHT + SITE_PADDING * 2;
childSite.setPosition(childCursorX, childSiteYStart);
childSite.setSize(childWidth, childHeight);
childCursorX += childWidth + 40;
childSitesMaxHeight = Math.max(childSitesMaxHeight, childHeight);
}
if (!isFinite(minX)) continue;
const childSitesTotalWidth = childCursorX - (cursorX + SITE_PADDING);
const childSitesExtra =
childSites.length > 0 ? childSitesMaxHeight + SITE_PADDING : 0;
const newX = minX - SITE_PADDING;
const newY = minY - SITE_HEADER_HEIGHT - SITE_PADDING;
const newWidth = maxX - minX + SITE_PADDING * 2;
const newHeight = maxY - minY + SITE_HEADER_HEIGHT + SITE_PADDING * 2;
// Calculate root site bounds
const rootWidth = Math.max(
rootResult.width + SITE_PADDING * 2,
childSitesTotalWidth,
250,
);
const rootHeight =
SITE_HEADER_HEIGHT +
SITE_PADDING +
rootResult.height +
childSitesExtra +
SITE_PADDING;
site.setPosition(newX, newY);
site.setSize(Math.max(newWidth, 250), Math.max(newHeight, 150));
rootSite.setPosition(cursorX, startY);
rootSite.setSize(rootWidth, rootHeight);
cursorX += rootWidth + 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',
);
}