import type { DiagramEdge, DiagramGeometry, DiagramNode, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js"; interface Obstacle extends DiagramGeometry { id: string; } function center(geometry: DiagramGeometry): DiagramPoint { return { x: geometry.x + geometry.width / 2, y: geometry.y + geometry.height / 2 }; } function endpointPair(source: DiagramGeometry, target: DiagramGeometry): { source: DiagramPoint; target: DiagramPoint } { const a = center(source); const b = center(target); const dx = b.x - a.x; const dy = b.y - a.y; if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? { source: { x: source.x + source.width, y: a.y }, target: { x: target.x, y: b.y } } : { source: { x: source.x, y: a.y }, target: { x: target.x + target.width, y: b.y } }; } return dy >= 0 ? { source: { x: a.x, y: source.y + source.height }, target: { x: b.x, y: target.y } } : { source: { x: a.x, y: source.y }, target: { x: b.x, y: target.y + target.height } }; } function outwardStub(point: DiagramPoint, geometry: DiagramGeometry, distance: number): DiagramPoint { const middle = center(geometry); return { x: point.x + Math.sign(point.x - middle.x) * distance, y: point.y + Math.sign(point.y - middle.y) * distance, }; } function compactPath(path: DiagramPoint[]): DiagramPoint[] { const deduped = path.filter((point, index) => index === 0 || point.x !== path[index - 1].x || point.y !== path[index - 1].y); return deduped.filter((point, index) => { if (index === 0 || index === deduped.length - 1) return true; const previous = deduped[index - 1]; const next = deduped[index + 1]; return !((previous.x === point.x && point.x === next.x) || (previous.y === point.y && point.y === next.y)); }); } function segmentIntersectsRect(a: DiagramPoint, b: DiagramPoint, rect: Obstacle, padding: number): boolean { const left = rect.x - padding; const right = rect.x + rect.width + padding; const top = rect.y - padding; const bottom = rect.y + rect.height + padding; if (a.y === b.y) { return a.y > top && a.y < bottom && Math.max(Math.min(a.x, b.x), left) < Math.min(Math.max(a.x, b.x), right); } if (a.x === b.x) { return a.x > left && a.x < right && Math.max(Math.min(a.y, b.y), top) < Math.min(Math.max(a.y, b.y), bottom); } return true; } function pathClear(path: DiagramPoint[], obstacles: Obstacle[], padding: number): boolean { for (let index = 0; index < path.length - 1; index += 1) { for (const obstacle of obstacles) { if (segmentIntersectsRect(path[index], path[index + 1], obstacle, padding)) return false; } } return true; } function pathLength(path: DiagramPoint[]): number { let total = 0; for (let index = 0; index < path.length - 1; index += 1) { total += Math.abs(path[index + 1].x - path[index].x) + Math.abs(path[index + 1].y - path[index].y); } return total; } function routedStyle(style?: string): string { const base = style ?? "rounded=0;orthogonalLoop=1;html=1;"; if (/edgeStyle=[^;]*;?/.test(base)) return base.replace(/edgeStyle=[^;]*;?/, "edgeStyle=none;"); return `edgeStyle=none;${base}`; } function absoluteGeometryById(page: DiagramPage): Map { const nodes = new Map(page.nodes.map((node) => [node.id, node])); const result = new Map(); const resolving = new Set(); const resolve = (id: string): DiagramGeometry | undefined => { const cached = result.get(id); if (cached) return cached; const node = nodes.get(id); if (!node?.geometry || resolving.has(id)) return undefined; resolving.add(id); const parent = node.parentId ? resolve(node.parentId) : undefined; resolving.delete(id); const absolute = { ...node.geometry, x: node.geometry.x + (parent?.x ?? 0), y: node.geometry.y + (parent?.y ?? 0), }; result.set(id, absolute); return absolute; }; for (const id of nodes.keys()) resolve(id); return result; } // Height of a container's label band. Mirrors ir-to-drawio's default container style (swimlane with // startSize=30) and draw.io's own swimlane default (40) when a custom style omits startSize. function headerHeight(node: DiagramNode): number { if (node.style === undefined) return node.kind === "container" ? 30 : 0; const explicit = /(?:^|;)startSize=(\d+(?:\.\d+)?)/.exec(node.style); if (explicit) return Number(explicit[1]); return /(?:^|;)swimlane(?:;|$)/.test(node.style) ? 40 : 0; } function ancestorIds(page: DiagramPage, id: string): Set { const nodes = new Map(page.nodes.map((node) => [node.id, node])); const ancestors = new Set(); let parentId = nodes.get(id)?.parentId; while (parentId && !ancestors.has(parentId)) { ancestors.add(parentId); parentId = nodes.get(parentId)?.parentId; } return ancestors; } function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map, padding: number, grid: number): DiagramEdge { if (edge.waypoints?.length) return { ...edge, waypoints: edge.waypoints.map((point) => ({ ...point })) }; const source = geometries.get(edge.source); const target = geometries.get(edge.target); if (!source || !target) return { ...edge }; const snap = (value: number) => Math.round(value / grid) * grid; if (edge.source === edge.target) { const right = snap(source.x + source.width + padding * 2); const top = snap(source.y - padding * 2); return { ...edge, style: routedStyle(edge.style), waypoints: [ { x: right, y: snap(source.y + source.height / 2) }, { x: right, y: top }, { x: snap(source.x + source.width / 2), y: top }, ], }; } const endpoints = endpointPair(source, target); const endpointAncestors = new Set([...ancestorIds(page, edge.source), ...ancestorIds(page, edge.target)]); // Containers enclosing an endpoint may be crossed, except for their header band: a route through the // label bar (or along its bottom line) merges visually with the container border. const obstacles: Obstacle[] = page.nodes .filter((node) => node.id !== edge.source && node.id !== edge.target && geometries.has(node.id)) .flatMap((node) => { const geometry = geometries.get(node.id)!; if (!endpointAncestors.has(node.id)) return [{ id: node.id, ...geometry }]; const header = headerHeight(node); return header > 0 ? [{ id: node.id, ...geometry, height: header }] : []; }); const sourceStub = outwardStub(endpoints.source, source, padding); const targetStub = outwardStub(endpoints.target, target, padding); const middleX = snap((sourceStub.x + targetStub.x) / 2); const middleY = snap((sourceStub.y + targetStub.y) / 2); const directCandidates: DiagramPoint[][] = endpoints.source.x === endpoints.target.x || endpoints.source.y === endpoints.target.y ? [[endpoints.source, endpoints.target]] : [ [endpoints.source, sourceStub, { x: middleX, y: sourceStub.y }, { x: middleX, y: targetStub.y }, targetStub, endpoints.target], [endpoints.source, sourceStub, { x: sourceStub.x, y: middleY }, { x: targetStub.x, y: middleY }, targetStub, endpoints.target], ]; const minY = Math.min(source.y, target.y, ...obstacles.map((item) => item.y)); const maxY = Math.max(source.y + source.height, target.y + target.height, ...obstacles.map((item) => item.y + item.height)); const minX = Math.min(source.x, target.x, ...obstacles.map((item) => item.x)); const maxX = Math.max(source.x + source.width, target.x + target.width, ...obstacles.map((item) => item.x + item.width)); const corridorCandidates: DiagramPoint[][] = [ [endpoints.source, sourceStub, { x: sourceStub.x, y: snap(minY - padding * 2) }, { x: targetStub.x, y: snap(minY - padding * 2) }, targetStub, endpoints.target], [endpoints.source, sourceStub, { x: sourceStub.x, y: snap(maxY + padding * 2) }, { x: targetStub.x, y: snap(maxY + padding * 2) }, targetStub, endpoints.target], [endpoints.source, sourceStub, { x: snap(minX - padding * 2), y: sourceStub.y }, { x: snap(minX - padding * 2), y: targetStub.y }, targetStub, endpoints.target], [endpoints.source, sourceStub, { x: snap(maxX + padding * 2), y: sourceStub.y }, { x: snap(maxX + padding * 2), y: targetStub.y }, targetStub, endpoints.target], ]; const candidates = [...directCandidates, ...corridorCandidates] .map(compactPath) .filter((path) => pathClear(path, obstacles, padding)) .sort((a, b) => pathLength(a) - pathLength(b) || JSON.stringify(a).localeCompare(JSON.stringify(b))); const selected = candidates[0]; if (!selected) return { ...edge }; return { ...edge, style: routedStyle(edge.style), waypoints: selected.slice(1, -1) }; } export function routePageEdges(page: DiagramPage, padding = 10): DiagramPage { const grid = page.layout?.gridSize ?? 10; const geometries = absoluteGeometryById(page); return { ...page, nodes: page.nodes.map((node) => ({ ...node, geometry: node.geometry ? { ...node.geometry } : undefined })), edges: page.edges.map((edge) => routeEdge(edge, page, geometries, padding, grid)), }; }