diff --git a/frontend/src/app/features/schema/helpers/data-mapper.ts b/frontend/src/app/features/schema/helpers/data-mapper.ts index 2d4061b..3a435c5 100644 --- a/frontend/src/app/features/schema/helpers/data-mapper.ts +++ b/frontend/src/app/features/schema/helpers/data-mapper.ts @@ -96,6 +96,7 @@ export function buildGraphData( entityType: 'site', entityId: site.id, }, + parent: site.parentSiteId ?? undefined, zIndex: 1, }); } diff --git a/frontend/src/app/features/schema/layout/dagre-layout.ts b/frontend/src/app/features/schema/layout/dagre-layout.ts index 221df38..352c6a3 100644 --- a/frontend/src/app/features/schema/layout/dagre-layout.ts +++ b/frontend/src/app/features/schema/layout/dagre-layout.ts @@ -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(); + 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(); - 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(); - 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(); + 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(); - 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(); 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', + ); +}