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',
|
entityType: 'site',
|
||||||
entityId: site.id,
|
entityId: site.id,
|
||||||
},
|
},
|
||||||
|
parent: site.parentSiteId ?? undefined,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,149 +1,243 @@
|
|||||||
import dagre from 'dagre';
|
import dagre from 'dagre';
|
||||||
import type { Graph, Node } from '@antv/x6';
|
import type { Graph, Node, Cell } from '@antv/x6';
|
||||||
import { SITE_PADDING, SITE_HEADER_HEIGHT } from '../../../constants/sizes';
|
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 {
|
function resolveDeviceId(graph: Graph, cellId: string): string | null {
|
||||||
const cell = graph.getCellById(cellId);
|
const cell = graph.getCellById(cellId);
|
||||||
if (!cell || !cell.isNode()) return null;
|
if (!cell || !cell.isNode()) return null;
|
||||||
const shape = cell.shape;
|
if (cell.shape === 'site-node') return null;
|
||||||
if (shape === 'site-node') return null;
|
if (cell.shape === 'card-node') {
|
||||||
if (shape === 'card-node') {
|
const parent = (cell as Node).getParent();
|
||||||
const parent = cell.getParent();
|
|
||||||
return parent ? parent.id : null;
|
return parent ? parent.id : null;
|
||||||
}
|
}
|
||||||
return cell.id;
|
return cell.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeviceEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies dagre hierarchical layout to the graph.
|
* Layout devices within a single site using dagre.
|
||||||
* Devices are positioned by dagre based on their connections.
|
* Uses LAYER_MAPPING to enforce vertical hierarchy via invisible edges.
|
||||||
* Site containers are then recalculated to wrap their children.
|
* Returns the bounding box size of the laid-out devices.
|
||||||
*/
|
*/
|
||||||
export function applyDagreLayout(graph: Graph): void {
|
function layoutSiteDevices(
|
||||||
const allNodes = graph.getNodes();
|
graph: Graph,
|
||||||
const allEdges = graph.getEdges();
|
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();
|
const g = new dagre.graphlib.Graph();
|
||||||
g.setDefaultEdgeLabel(() => ({}));
|
g.setDefaultEdgeLabel(() => ({}));
|
||||||
g.setGraph({
|
g.setGraph({
|
||||||
rankdir: 'TB',
|
rankdir: 'TB',
|
||||||
nodesep: 50,
|
nodesep: 50,
|
||||||
ranksep: 70,
|
ranksep: 70,
|
||||||
marginx: 50,
|
marginx: 20,
|
||||||
marginy: 50,
|
marginy: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const node of deviceNodes) {
|
const deviceIdSet = new Set(devices.map((d) => d.id));
|
||||||
const size = node.getSize();
|
|
||||||
g.setNode(node.id, { width: size.width, height: size.height });
|
// 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>();
|
const edgeSet = new Set<string>();
|
||||||
for (const edge of allEdges) {
|
for (const { source, target } of edges) {
|
||||||
const sourceDevice = resolveDeviceId(graph, edge.getSourceCellId());
|
if (!deviceIdSet.has(source) || !deviceIdSet.has(target)) continue;
|
||||||
const targetDevice = resolveDeviceId(graph, edge.getTargetCellId());
|
if (source === target) continue;
|
||||||
if (!sourceDevice || !targetDevice) continue;
|
const key = `${source}->${target}`;
|
||||||
if (sourceDevice === targetDevice) continue; // skip self-loops
|
|
||||||
const key = `${sourceDevice}->${targetDevice}`;
|
|
||||||
if (edgeSet.has(key)) continue;
|
if (edgeSet.has(key)) continue;
|
||||||
edgeSet.add(key);
|
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);
|
dagre.layout(g);
|
||||||
|
|
||||||
// Apply positions inside a batch for single undo step
|
// Apply positions with offset (dagre returns center coords)
|
||||||
graph.startBatch('dagre-layout');
|
|
||||||
|
|
||||||
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) {
|
||||||
// dagre returns center coordinates, convert to top-left
|
node.setPosition(
|
||||||
node.setPosition(pos.x - pos.width / 2, pos.y - pos.height / 2);
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
const graphInfo = g.graph();
|
||||||
// We use the data approach: check if any site is a child of another
|
return {
|
||||||
// Sort: leaf sites first (those with no child sites inside them)
|
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>();
|
const childSiteIds = new Set<string>();
|
||||||
for (const site of siteNodes) {
|
for (const site of siteNodes) {
|
||||||
const children = (site.getChildren() ?? []).filter(
|
const children = site.getChildren() ?? [];
|
||||||
(c): c is Node => c.isNode() && c.shape === 'site-node',
|
|
||||||
);
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
|
if (child.isNode() && child.shape === 'site-node') {
|
||||||
childSiteIds.add(child.id);
|
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;
|
graph.startBatch('dagre-layout');
|
||||||
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;
|
|
||||||
|
|
||||||
site.setPosition(newX, newY);
|
let cursorX = 50;
|
||||||
site.setSize(Math.max(newWidth, 250), Math.max(newHeight, 150));
|
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');
|
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