fix: per-site dagre layout with layer hierarchy
All checks were successful
continuous-integration/drone/push Build is passing
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:
@ -96,6 +96,7 @@ export function buildGraphData(
|
||||
entityType: 'site',
|
||||
entityId: site.id,
|
||||
},
|
||||
parent: site.parentSiteId ?? undefined,
|
||||
zIndex: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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',
|
||||
node.setPosition(
|
||||
offsetX + pos.x - pos.width / 2,
|
||||
offsetY + pos.y - pos.height / 2,
|
||||
);
|
||||
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)
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)),
|
||||
];
|
||||
|
||||
for (const site of sortedSites) {
|
||||
const children = site.getChildren() ?? [];
|
||||
const childNodes = children.filter((c): c is Node => c.isNode());
|
||||
if (childNodes.length === 0) continue;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) continue;
|
||||
const rootSites = siteNodes.filter((s) => !childSiteIds.has(s.id));
|
||||
|
||||
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;
|
||||
graph.startBatch('dagre-layout');
|
||||
|
||||
site.setPosition(newX, newY);
|
||||
site.setSize(Math.max(newWidth, 250), Math.max(newHeight, 150));
|
||||
let cursorX = 50;
|
||||
const siteGap = 120;
|
||||
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(
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
250,
|
||||
);
|
||||
const rootHeight =
|
||||
SITE_HEADER_HEIGHT +
|
||||
SITE_PADDING +
|
||||
rootResult.height +
|
||||
childSitesExtra +
|
||||
SITE_PADDING;
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user