mermaid
Version:
Markdown-ish syntax for generating flowcharts, mindmaps, sequence diagrams, class diagrams, gantt charts, git graphs and more.
4 lines • 648 kB
Source Map (JSON)
{
"version": 3,
"sources": ["../../../src/rendering-util/createGraph.ts", "../../../src/rendering-util/rendering-elements/lineJump.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/adjustLayout.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/helpers.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/edgeLabelNodes.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/geometry.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/endpointClip.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/lrTransform.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/portSwap.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/terminalStub.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/materializedGeometry.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/detourSimplification.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/labelAnchoring.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/siblingSharedFaceRouting.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/sharedTrackNudging.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/direction/validation.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/postProcessing.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase0.helpers.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase1.cycles.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.options.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/config.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/driving-tree.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.crossCounts.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.multitree.core.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.multitree.order.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.crossOptimization.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.crossLaneAdjust.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.longestPath.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.gravity.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.laneAwareCompact.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase2.dummies.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase3.ordering.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/phase4.coordinates.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/laneOrdering.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/pipeline.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/orthogonalRouter/router.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/layoutCore.ts", "../../../src/rendering-util/layout-algorithms/swimlanes/index.ts"],
"sourcesContent": ["import type { Selection } from 'd3';\nimport * as graphlib from 'dagre-d3-es/src/graphlib/index.js';\nimport type { LayoutData } from './types.js';\nimport { getConfig } from '../diagram-api/diagramAPI.js';\nimport { insertNode } from './rendering-elements/nodes.js';\n\n// Update type:\ntype D3Selection<T extends SVGElement = SVGElement> = Selection<\n T,\n unknown,\n Element | null,\n unknown\n>;\n\n/**\n * Creates a graph by merging the graph construction and DOM element insertion.\n *\n * This function creates the graph, inserts the SVG groups (clusters, edgePaths, edgeLabels, nodes)\n * into the provided element, and uses `insertNode` to add nodes to the diagram. Node dimensions\n * are computed using each node's bounding box.\n *\n * @param element - The D3 selection in which the SVG groups are inserted.\n * @param data4Layout - The layout data containing nodes and edges.\n * @returns A promise resolving to an object containing the graph and the inserted groups.\n */\nexport async function createGraphWithElements(\n element: D3Selection,\n data4Layout: LayoutData\n): Promise<{\n graph: graphlib.Graph;\n groups: {\n clusters: D3Selection<SVGGElement>;\n edgePaths: D3Selection<SVGGElement>;\n edgeLabels: D3Selection<SVGGElement>;\n nodes: D3Selection<SVGGElement>;\n rootGroups: D3Selection<SVGGElement>;\n };\n nodeElements: Map<string, D3Selection<SVGElement | SVGGElement>>;\n}> {\n // Create a directed, multi graph.\n const graph = new graphlib.Graph({\n multigraph: true,\n compound: true,\n });\n const edgesToProcess = [...data4Layout.edges];\n const config = getConfig();\n // Create groups for clusters, edge paths, edge labels, and nodes.\n const rootGroups = element.insert('g').attr('class', 'root');\n const clusters = rootGroups.insert('g').attr('class', 'clusters');\n const edgePaths = rootGroups.insert('g').attr('class', 'edges edgePath');\n const edgeLabels = rootGroups.insert('g').attr('class', 'edgeLabels');\n const nodesGroup = rootGroups.insert('g').attr('class', 'nodes');\n\n const nodeElements = new Map<string, D3Selection<SVGElement | SVGGElement>>();\n\n // When the container element is detached (no real DOM \u2014 e.g. headless unit\n // tests that exercise the layout engine without rendering), `insertNode`\n // cannot measure labels and would dereference a null node. The browser\n // always passes a live container, so render + measure only when one exists;\n // otherwise still build the graph topology with unmeasured (0) sizes.\n const hasDom = element.node() != null;\n\n // Insert nodes into the DOM and add them to the graph.\n await Promise.all(\n data4Layout.nodes.map(async (node) => {\n if (node.isGroup) {\n graph.setNode(node.id, { ...node });\n } else {\n if (hasDom) {\n const childNodeEl = await insertNode(nodesGroup, node, { config, dir: node.dir });\n const boundingBox = childNodeEl.node()?.getBBox() ?? { width: 0, height: 0 };\n nodeElements.set(node.id, childNodeEl as D3Selection<SVGElement | SVGGElement>);\n node.width = boundingBox.width;\n node.height = boundingBox.height;\n }\n graph.setNode(node.id, { ...node });\n }\n })\n );\n // Add edges to the graph.\n\n for (const edge of edgesToProcess) {\n graph.setEdge(edge.start!, edge.end!, { ...edge }, edge.id);\n const edgeExists = data4Layout.edges.some((existingEdge) => existingEdge.id === edge.id);\n if (!edgeExists) {\n data4Layout.edges.push(edge);\n }\n }\n\n // DDLT size capture (dev / test tooling only). The capture module is loaded\n // via dynamic import so it is never bundled into the production render path:\n // in published builds `window.mermaidCaptureSizes` is unset, so this guard is\n // a single property read and the import resolves to a lazily-loaded chunk that\n // is only fetched when a developer explicitly enables capture.\n // See layout-algorithms/ddlt/sizeCapture.ts.\n if ((globalThis as unknown as { mermaidCaptureSizes?: boolean }).mermaidCaptureSizes) {\n const { captureNodeSizes } = await import('./layout-algorithms/ddlt/sizeCapture.js');\n captureNodeSizes(element, data4Layout);\n }\n\n return {\n graph,\n groups: { clusters, edgePaths, edgeLabels, nodes: nodesGroup, rootGroups },\n nodeElements,\n };\n}\n", "/**\n * Line jumps (\"hops\") for edge crossings.\n *\n * Detects true segment crossings between edge polylines and rewrites the SVG\n * path of the later edge so the crossing renders as either a small arc\n * (`jumpStyle: 'arc'`) or a visible break (`jumpStyle: 'gap'`).\n *\n * The pure functions (`findEdgeIntersections`, `processEdgesWithJumps`) are\n * DOM-free. The DOM-side `applyLineJumpsToSvg` helper reads geometry from\n * layout data and leaves curved (non-`M`/`L`) rendered paths untouched.\n */\n\nimport type { D3Selection } from '../../types.js';\nimport { markerOffsets } from '../../utils/lineWithOffset.js';\n\n/** Radius used by edges.js' generateRoundedPath. Kept in sync so rewritten\n * paths look like the originals at bends. */\nconst ROUNDED_CORNER_RADIUS = 5;\n\n/** Skip the jump if its clamped radius falls below this \u2014 avoids invisible\n * zero-length arcs on very crowded paths. */\nconst CORNER_EPSILON = 1e-5;\n\nexport interface Point {\n x: number;\n y: number;\n}\n\nexport interface EdgeGeom {\n id: string;\n points: Point[];\n /**\n * Optional curve hint matching `edge.curve` from the rendering layer.\n * When set, line jumps are only applied for orthogonal-friendly curves\n * (`'linear'`, `'rounded'`, `'step'`, `'stepBefore'`, `'stepAfter'`, or\n * undefined). Other curves (basis, monotoneX, \u2026) are skipped to avoid\n * corrupting smoothed geometry.\n */\n curve?: string;\n /** Arrow type at the start (first point) \u2014 used to apply marker offset so\n * the rewritten path's endpoint matches the original rendered geometry and\n * the arrow marker orients correctly. */\n arrowTypeStart?: string;\n /** Arrow type at the end (last point). */\n arrowTypeEnd?: string;\n}\n\nexport interface LineJumpConfig {\n enabled: boolean;\n jumpRadius: number;\n jumpStyle: 'arc' | 'gap';\n}\n\nexport interface Crossing {\n jumpEdgeId: string;\n otherEdgeId: string;\n /** Index of the segment within the jumping edge's polyline. */\n segIndex: number;\n /** Position of the crossing along the jumping edge's segment, 0..1. */\n t: number;\n point: Point;\n}\n\nconst ENDPOINT_EPSILON = 1e-6;\n\ninterface Segment {\n a: Point;\n b: Point;\n}\n\nfunction buildSegmentList(points: Point[]): Segment[] {\n const segments: Segment[] = [];\n for (let i = 0; i < points.length - 1; i++) {\n segments.push({ a: points[i], b: points[i + 1] });\n }\n return segments;\n}\n\ninterface SegmentIntersection {\n point: Point;\n tA: number;\n tB: number;\n}\n\n/**\n * Parametric segment-segment intersection. Returns null if the segments are\n * parallel, do not intersect, or only meet at one of their endpoints (within\n * `ENDPOINT_EPSILON`). Endpoint rejection prevents normal joins, T-junctions,\n * and shared-start edges from being treated as crossings.\n */\nfunction segmentIntersection(\n a1: Point,\n a2: Point,\n b1: Point,\n b2: Point\n): SegmentIntersection | null {\n const dxA = a2.x - a1.x;\n const dyA = a2.y - a1.y;\n const dxB = b2.x - b1.x;\n const dyB = b2.y - b1.y;\n\n const denom = dxA * dyB - dyA * dxB;\n if (denom === 0) {\n return null;\n }\n\n const dx = b1.x - a1.x;\n const dy = b1.y - a1.y;\n\n const tA = (dx * dyB - dy * dxB) / denom;\n const tB = (dx * dyA - dy * dxA) / denom;\n\n if (\n tA <= ENDPOINT_EPSILON ||\n tA >= 1 - ENDPOINT_EPSILON ||\n tB <= ENDPOINT_EPSILON ||\n tB >= 1 - ENDPOINT_EPSILON\n ) {\n return null;\n }\n\n return {\n point: { x: a1.x + tA * dxA, y: a1.y + tA * dyA },\n tA,\n tB,\n };\n}\n\n/** True if the segment is horizontally dominant (abs(dx) is at least abs(dy)).\n * Ties go to horizontal to keep pure-diagonal edges grouped with the\n * horizontal bucket \u2014 they don't occur in orthogonal layouts anyway. */\nfunction isHorizontalSeg(seg: Segment): boolean {\n return Math.abs(seg.b.x - seg.a.x) >= Math.abs(seg.b.y - seg.a.y);\n}\n\nexport function findEdgeIntersections(edges: EdgeGeom[]): Crossing[] {\n const crossings: Crossing[] = [];\n\n for (let i = 0; i < edges.length; i++) {\n const edgeA = edges[i];\n const segmentsA = buildSegmentList(edgeA.points);\n for (let j = i + 1; j < edges.length; j++) {\n const edgeB = edges[j];\n const segmentsB = buildSegmentList(edgeB.points);\n\n for (const [si, segA] of segmentsA.entries()) {\n for (const [sj, segB] of segmentsB.entries()) {\n const hit = segmentIntersection(segA.a, segA.b, segB.a, segB.b);\n if (!hit) {\n continue;\n }\n\n // Orthogonal-orientation rule: when one segment is horizontal-\n // dominant and the other vertical-dominant, the HORIZONTAL one\n // gets the jump (classic line-hop convention \u2014 arcs arch upward\n // over the vertical line beneath). Falls back to later-index-wins\n // when both segments share an orientation.\n const aHoriz = isHorizontalSeg(segA);\n const bHoriz = isHorizontalSeg(segB);\n const orthogonalPair = aHoriz !== bHoriz;\n const jumpOnA = orthogonalPair ? aHoriz : false;\n\n if (jumpOnA) {\n crossings.push({\n jumpEdgeId: edgeA.id,\n otherEdgeId: edgeB.id,\n segIndex: si,\n t: hit.tA,\n point: hit.point,\n });\n } else {\n crossings.push({\n jumpEdgeId: edgeB.id,\n otherEdgeId: edgeA.id,\n segIndex: sj,\n t: hit.tB,\n point: hit.point,\n });\n }\n }\n }\n }\n }\n\n return crossings;\n}\n\nfunction fmt(n: number): string {\n // Strip trailing zeros so \"5.00\" \u2192 \"5\"; keep up to 3 decimals otherwise.\n const rounded = Math.round(n * 1000) / 1000;\n return Number.isInteger(rounded) ? `${rounded}` : `${rounded}`;\n}\n\nfunction pointToString(p: Point): string {\n return `${fmt(p.x)},${fmt(p.y)}`;\n}\n\n/**\n * Determines the SVG arc sweep flag so the jump bumps in the conventional\n * direction: horizontal segments bump up (smaller y in SVG), vertical segments\n * bump right (larger x).\n */\nfunction getArcSweepFlag(seg: Segment): 0 | 1 {\n const dx = seg.b.x - seg.a.x;\n const dy = seg.b.y - seg.a.y;\n if (Math.abs(dx) >= Math.abs(dy)) {\n // Horizontal-dominant: bump up (smaller y in SVG's y-down frame).\n // Going +x \u2192 sweep=1 sweeps through increasing angle 180\u00B0\u2192270\u00B0\u21920\u00B0,\n // which passes through (mid, y-r) = up.\n // Going -x \u2192 sweep=0 (reverse direction) also lands the bump above.\n return dx >= 0 ? 1 : 0;\n }\n // Vertical-dominant: bump right (positive x).\n // Going +y \u2192 sweep=1; going -y \u2192 sweep=0.\n return dy >= 0 ? 1 : 0;\n}\n\ninterface JumpOnSegment {\n t: number;\n point: Point;\n /** Distance from segment start along the segment direction. */\n d: number;\n /** Effective radius after boundary + adjacency clamping. */\n r: number;\n}\n\nconst MIN_JUMP_RADIUS = 1e-3;\n\n/**\n * Shifts the first/last point inward along the edge direction by the amount\n * required for their arrow markers, matching `applyMarkerOffsetsToPoints` in\n * edges.js so the rewritten path ends exactly where the original did.\n */\nfunction applyMarkerOffsets(points: Point[], edge: EdgeGeom): Point[] {\n if (points.length < 2) {\n return points.map((p) => ({ ...p }));\n }\n const out = points.map((p) => ({ ...p }));\n const startOff =\n edge.arrowTypeStart && markerOffsets[edge.arrowTypeStart as keyof typeof markerOffsets];\n if (startOff) {\n const a = points[0];\n const b = points[1];\n const ang = Math.atan2(b.y - a.y, b.x - a.x);\n out[0].x = a.x + startOff * Math.cos(ang);\n out[0].y = a.y + startOff * Math.sin(ang);\n }\n const endOff =\n edge.arrowTypeEnd && markerOffsets[edge.arrowTypeEnd as keyof typeof markerOffsets];\n if (endOff) {\n const n = points.length;\n const a = points[n - 2];\n const b = points[n - 1];\n const ang = Math.atan2(b.y - a.y, b.x - a.x);\n out[n - 1].x = b.x - endOff * Math.cos(ang);\n out[n - 1].y = b.y - endOff * Math.sin(ang);\n }\n return out;\n}\n\n/**\n * Emits the arc or gap command for a crossing, in the segment's direction.\n * Returns the part strings; caller inserts them in order.\n */\nfunction emitJump(\n jump: JumpOnSegment,\n ux: number,\n uy: number,\n sweep: 0 | 1,\n style: 'arc' | 'gap'\n): string[] {\n const cx = jump.point.x;\n const cy = jump.point.y;\n const pre = { x: cx - ux * jump.r, y: cy - uy * jump.r };\n const post = { x: cx + ux * jump.r, y: cy + uy * jump.r };\n const out = [`L${pointToString(pre)}`];\n if (style === 'arc') {\n out.push(`A${fmt(jump.r)},${fmt(jump.r)} 0 0 ${sweep} ${pointToString(post)}`);\n } else {\n out.push(`M${pointToString(post)}`);\n }\n return out;\n}\n\n/**\n * Mirrors the corner-rounding logic of `generateRoundedPath` in edges.js:\n * given a bend at `curr` between segments `prev\u2192curr` and `curr\u2192next`,\n * computes (startX, startY) just before curr on the incoming segment and\n * (endX, endY) just after curr on the outgoing segment, plus the Q control\n * point (which is curr itself). Returns `null` if the angle is degenerate\n * and the caller should just emit a straight `L curr`.\n */\ninterface RoundedCorner {\n startX: number;\n startY: number;\n endX: number;\n endY: number;\n ctrlX: number;\n ctrlY: number;\n /** How much the start of the rounded corner eats into the incoming segment. */\n cutLen: number;\n}\nfunction computeRoundedCorner(\n prev: Point,\n curr: Point,\n next: Point,\n radius: number\n): RoundedCorner | null {\n const dx1 = curr.x - prev.x;\n const dy1 = curr.y - prev.y;\n const dx2 = next.x - curr.x;\n const dy2 = next.y - curr.y;\n const len1 = Math.hypot(dx1, dy1);\n const len2 = Math.hypot(dx2, dy2);\n if (len1 < CORNER_EPSILON || len2 < CORNER_EPSILON) {\n return null;\n }\n const nx1 = dx1 / len1;\n const ny1 = dy1 / len1;\n const nx2 = dx2 / len2;\n const ny2 = dy2 / len2;\n const dot = nx1 * nx2 + ny1 * ny2;\n const clamped = Math.max(-1, Math.min(1, dot));\n const angle = Math.acos(clamped);\n if (angle < CORNER_EPSILON || Math.abs(Math.PI - angle) < CORNER_EPSILON) {\n return null;\n }\n const cutLen = Math.min(radius / Math.sin(angle / 2), len1 / 2, len2 / 2);\n return {\n startX: curr.x - nx1 * cutLen,\n startY: curr.y - ny1 * cutLen,\n endX: curr.x + nx2 * cutLen,\n endY: curr.y + ny2 * cutLen,\n ctrlX: curr.x,\n ctrlY: curr.y,\n cutLen,\n };\n}\n\nfunction rewriteEdgePath(edge: EdgeGeom, jumps: Crossing[], config: LineJumpConfig): string {\n const rawPoints = edge.points;\n if (rawPoints.length < 2) {\n return '';\n }\n\n // Match edges.js: shift the first/last point inward so arrow markers line up.\n const points = applyMarkerOffsets(rawPoints, edge);\n const rounded = edge.curve === 'rounded';\n\n // Jumps are indexed into the ORIGINAL (un-offset) segment list. For mid-\n // segments (i > 0 and i < n-2) the offsets don't change anything, and for\n // the first/last segment the shift is tiny compared to jump radius so\n // reusing the same (segIndex, t) is fine.\n const segments = buildSegmentList(points);\n const bySeg = new Map<number, JumpOnSegment[]>();\n for (const j of jumps) {\n const seg = segments[j.segIndex];\n if (!seg) {\n continue;\n }\n const segLen = Math.hypot(seg.b.x - seg.a.x, seg.b.y - seg.a.y);\n const list = bySeg.get(j.segIndex) ?? [];\n list.push({\n t: j.t,\n point: j.point,\n d: j.t * segLen,\n r: config.jumpRadius,\n });\n bySeg.set(j.segIndex, list);\n }\n\n const parts: string[] = [`M${pointToString(points[0])}`];\n // Running cursor along the current segment measured from seg.a.\n // Consumed at the front by the previous corner's cutLen (for rounded) and\n // after that by mid-segment jumps.\n for (let i = 0; i < segments.length; i++) {\n const seg = segments[i];\n const segLen = Math.hypot(seg.b.x - seg.a.x, seg.b.y - seg.a.y);\n const ux = segLen === 0 ? 0 : (seg.b.x - seg.a.x) / segLen;\n const uy = segLen === 0 ? 0 : (seg.b.y - seg.a.y) / segLen;\n const sweep = getArcSweepFlag(seg);\n\n // How much of the front of this segment was consumed by the previous\n // corner's Q end-point (endX,endY). Default 0.\n let segStartConsumed = 0;\n if (rounded && i > 0) {\n const corner = computeRoundedCorner(\n points[i - 1],\n points[i],\n points[i + 1] ?? points[i],\n ROUNDED_CORNER_RADIUS\n );\n if (corner) {\n segStartConsumed = corner.cutLen;\n }\n }\n\n // Rounded: if there's a next corner ahead, we stop short of it by cutLen.\n let segEndStop = segLen;\n let upcomingCorner: RoundedCorner | null = null;\n if (rounded && i < segments.length - 1) {\n upcomingCorner = computeRoundedCorner(\n points[i],\n points[i + 1],\n points[i + 2] ?? points[i + 1],\n ROUNDED_CORNER_RADIUS\n );\n if (upcomingCorner) {\n segEndStop = segLen - upcomingCorner.cutLen;\n }\n }\n\n // Jumps clamped so they don't overlap corners at either end of the\n // segment or each other.\n const segJumps = [...(bySeg.get(i) ?? [])].sort((a, b) => a.t - b.t);\n for (const j of segJumps) {\n j.r = Math.min(j.r, j.d - segStartConsumed, segEndStop - j.d);\n }\n for (let k = 0; k < segJumps.length - 1; k++) {\n const gap = segJumps[k + 1].d - segJumps[k].d;\n if (segJumps[k].r + segJumps[k + 1].r > gap) {\n const half = gap / 2;\n segJumps[k].r = Math.min(segJumps[k].r, half);\n segJumps[k + 1].r = Math.min(segJumps[k + 1].r, half);\n }\n }\n\n for (const j of segJumps) {\n if (j.r < MIN_JUMP_RADIUS) {\n continue;\n }\n parts.push(...emitJump(j, ux, uy, sweep, config.jumpStyle));\n }\n\n // End of segment: either a straight L to seg.b (last segment or linear),\n // or a Q-corner into seg.b's neighborhood (rounded, middle).\n if (rounded && upcomingCorner) {\n parts.push(`L${fmt(upcomingCorner.startX)},${fmt(upcomingCorner.startY)}`);\n parts.push(\n `Q${fmt(upcomingCorner.ctrlX)},${fmt(upcomingCorner.ctrlY)} ${fmt(upcomingCorner.endX)},${fmt(upcomingCorner.endY)}`\n );\n } else {\n parts.push(`L${pointToString(seg.b)}`);\n }\n }\n\n return parts.join(' ');\n}\n\nfunction plainPath(points: Point[]): string {\n if (points.length === 0) {\n return '';\n }\n const parts = [`M${pointToString(points[0])}`];\n for (let i = 1; i < points.length; i++) {\n parts.push(`L${pointToString(points[i])}`);\n }\n return parts.join(' ');\n}\n\nexport function processEdgesWithJumps(\n edges: EdgeGeom[],\n config: LineJumpConfig\n): Map<string, string> {\n const result = new Map<string, string>();\n\n if (!config.enabled) {\n for (const edge of edges) {\n result.set(edge.id, plainPath(edge.points));\n }\n return result;\n }\n\n const crossings = findEdgeIntersections(edges);\n const jumpsByEdge = new Map<string, Crossing[]>();\n for (const c of crossings) {\n const list = jumpsByEdge.get(c.jumpEdgeId) ?? [];\n list.push(c);\n jumpsByEdge.set(c.jumpEdgeId, list);\n }\n\n for (const edge of edges) {\n const jumps = jumpsByEdge.get(edge.id);\n if (!jumps || jumps.length === 0) {\n result.set(edge.id, plainPath(edge.points));\n } else {\n result.set(edge.id, rewriteEdgePath(edge, jumps, config));\n }\n }\n\n return result;\n}\n\n/**\n * Returns true iff the SVG path `d` is a straight-line path \u2014 only `M`/`L`/`m`/`l`\n * move/line commands plus their numeric coordinates (digits, sign, decimal point,\n * scientific-notation `e`, and `,`/space separators). Curved paths are skipped by\n * the caller.\n */\nexport function isStraightPath(d: string): boolean {\n return /^[\\d\\s+,.LMelm-]*$/.test(d);\n}\n\n/**\n * Returns true iff the named curve produces orthogonal-friendly segments that\n * can be safely re-emitted with line jumps. Includes `'rounded'` even though\n * its rendered `d` contains `Q` corner-rounding commands \u2014 when an edge with\n * a jump is rewritten the corner rounding is dropped in exchange for visible\n * arc hops at crossings, which is the desired trade-off.\n */\nexport function curveSupportsLineHops(curve: string | undefined): boolean {\n if (!curve) {\n return true;\n }\n return (\n curve === 'linear' ||\n curve === 'rounded' ||\n curve === 'step' ||\n curve === 'stepBefore' ||\n curve === 'stepAfter'\n );\n}\n\n/**\n * Decodes the `data-points` attribute set by edges.js at render time. This\n * gives us the exact point list edges.js used to emit the rendered path \u2014\n * i.e. after node-boundary `intersect()` clipping and any orthogonalization,\n * but BEFORE `applyMarkerOffsetsToPoints`. Using these points guarantees the\n * rewrite's endpoints match the original rendered endpoints.\n */\nfunction decodeDataPoints(raw: string | null): Point[] | null {\n if (!raw) {\n return null;\n }\n try {\n const json = typeof atob === 'function' ? atob(raw) : Buffer.from(raw, 'base64').toString();\n const parsed = JSON.parse(json);\n if (!Array.isArray(parsed)) {\n return null;\n }\n const pts: Point[] = [];\n for (const p of parsed) {\n if (p && typeof p.x === 'number' && typeof p.y === 'number') {\n pts.push({ x: p.x, y: p.y });\n }\n }\n return pts.length >= 2 ? pts : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Patches the rendered SVG paths in `edgePathsGroup` for any edges that\n * cross. The true geometry is read from each path's `data-points` attribute\n * (written by edges.js at render time) so the rewrite's endpoints match\n * exactly what was originally rendered. Edges whose curve is a true\n * smoothing curve (`basis`, `monotoneX`, \u2026) are skipped.\n */\nexport function applyLineJumpsToSvg(\n edgePathsGroup: D3Selection<SVGGElement>,\n edges: EdgeGeom[],\n config: LineJumpConfig\n): void {\n if (!config.enabled) {\n return;\n }\n\n const groupNode = edgePathsGroup.node();\n if (!groupNode) {\n return;\n }\n\n // Build a metadata lookup so per-edge properties (curve, arrow types)\n // survive the DOM round-trip.\n const edgeMeta = new Map<string, EdgeGeom>();\n for (const e of edges) {\n edgeMeta.set(e.id, e);\n }\n\n // Collect geometry from each path's data-points, preferring that over the\n // incoming `edges[].points` which came from pre-render layout state.\n const renderedEdges: EdgeGeom[] = [];\n const pathById = new Map<string, Element>();\n for (const e of edges) {\n const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(e.id) : e.id;\n const pathEl = groupNode.querySelector(`path[data-id=\"${escapedId}\"]`);\n if (!pathEl) {\n continue;\n }\n pathById.set(e.id, pathEl);\n const decoded = decodeDataPoints(pathEl.getAttribute('data-points'));\n const points = decoded ?? e.points;\n renderedEdges.push({ ...e, points });\n }\n\n const crossings = findEdgeIntersections(renderedEdges);\n if (crossings.length === 0) {\n return;\n }\n\n const jumpsByEdge = new Map<string, Crossing[]>();\n for (const c of crossings) {\n const list = jumpsByEdge.get(c.jumpEdgeId) ?? [];\n list.push(c);\n jumpsByEdge.set(c.jumpEdgeId, list);\n }\n\n for (const renderedEdge of renderedEdges) {\n const jumps = jumpsByEdge.get(renderedEdge.id);\n if (!jumps || jumps.length === 0) {\n continue;\n }\n const meta = edgeMeta.get(renderedEdge.id);\n const curveHint = meta?.curve;\n if (curveHint !== undefined && !curveSupportsLineHops(curveHint)) {\n continue;\n }\n\n const pathEl = pathById.get(renderedEdge.id);\n if (!pathEl) {\n continue;\n }\n\n if (curveHint === undefined) {\n const currentD = pathEl.getAttribute('d') ?? '';\n if (!isStraightPath(currentD)) {\n continue;\n }\n }\n\n // Read the ORIGINAL stroke-dasharray before rewriting so we can\n // recompute it against the new total length. The `neo` look emits:\n // stroke-dasharray: 0 <oValueS> <len - oValueS - oValueE> <oValueE>;\n // which hides the first oValueS and last oValueE pixels of the stroke\n // \u2014 this is what actually prevents the stroke from poking into the arrow\n // marker body. Our rewritten path has a different length, so without\n // updating the \"on\" portion the hidden tail ends up in the wrong place.\n const originalStyle = pathEl.getAttribute('style') ?? '';\n const dasharrayMatch = /stroke-dasharray\\s*:\\s*0\\s+([\\d.]+)\\s+[\\d.]+\\s+([\\d.]+)/.exec(\n originalStyle\n );\n const preservedOValueS = dasharrayMatch ? Number.parseFloat(dasharrayMatch[1]) : null;\n const preservedOValueE = dasharrayMatch ? Number.parseFloat(dasharrayMatch[2]) : null;\n\n const newD = rewriteEdgePath(renderedEdge, jumps, config);\n pathEl.setAttribute('d', newD);\n\n if (\n preservedOValueS !== null &&\n preservedOValueE !== null &&\n typeof (pathEl as SVGPathElement).getTotalLength === 'function'\n ) {\n const newLen = (pathEl as SVGPathElement).getTotalLength();\n const onLen = Math.max(0, newLen - preservedOValueS - preservedOValueE);\n const newDasharray = `0 ${preservedOValueS} ${onLen} ${preservedOValueE}`;\n const cleaned = originalStyle\n .replace(/stroke-dasharray\\s*:[^;]*;?/g, `stroke-dasharray: ${newDasharray};`)\n .replace(/;\\s*;+/g, ';');\n pathEl.setAttribute('style', cleaned);\n }\n }\n}\n", "import type { LayoutData } from '../../types.js';\nimport { positionNode } from '../../rendering-elements/nodes.js';\nimport type { D3Selection } from '../../../types.js';\nimport { insertCluster } from '../../rendering-elements/clusters.js';\nimport {\n edgeLabels,\n insertEdge,\n insertEdgeLabel,\n terminalLabels,\n} from '../../rendering-elements/edges.js';\nimport { applyLineJumpsToSvg } from '../../rendering-elements/lineJump.js';\nimport { log } from '../../../logger.js';\nimport { getSubGraphTitleMargins } from '../../../utils/subGraphTitleMargins.js';\nimport { getConfig } from '../../../config.js';\nimport utils from '../../../utils.js';\n\nexport async function adjustLayout(\n data4Layout: LayoutData,\n groups: {\n edgePaths: D3Selection<SVGGElement>;\n rootGroups: D3Selection<SVGGElement>;\n [key: string]: D3Selection<SVGGElement>;\n edgeLabels: D3Selection<SVGGElement>;\n }\n): Promise<void> {\n // Render clusters and position nodes; this also populates node.intersect on shapes.\n for (const node of data4Layout.nodes) {\n if (node.isGroup) {\n await insertCluster(groups.clusters, node);\n } else {\n positionNode(node);\n }\n }\n\n // Build a lookup so we can pass full node objects (with intersect) to insertEdge,\n // matching the behavior of the dagre-based pipeline.\n const nodeById = new Map<string, any>();\n for (const node of data4Layout.nodes) {\n if (node?.id) {\n nodeById.set(node.id, node);\n }\n }\n\n for (const edge of data4Layout.edges) {\n const startNode = edge.start ? (nodeById.get(edge.start) ?? {}) : {};\n const endNode = edge.end ? (nodeById.get(edge.end) ?? {}) : {};\n\n const paths = insertEdge(\n groups.edgePaths,\n { ...edge },\n {},\n data4Layout.type,\n startNode,\n endNode,\n data4Layout.diagramId\n );\n if (edge.label) {\n await insertEdgeLabel(groups.rootGroups, edge);\n }\n\n if (edge.label) {\n positionEdgeLabel(edge, paths);\n }\n }\n\n // Render-time post-processing: replace edge crossings with line hops.\n // Default: 'arc'. Set swimlane.lineHops = false to opt out.\n const lineHopsConfig = data4Layout.config?.swimlane?.lineHops;\n if (lineHopsConfig !== false) {\n const jumpStyle: 'arc' | 'gap' = lineHopsConfig === 'gap' ? 'gap' : 'arc';\n const edgeGeometries = data4Layout.edges\n .filter((e: any) => Array.isArray(e.points) && e.points.length >= 2)\n .map((e: any) => ({\n id: e.id,\n points: e.points,\n curve: e.curve,\n arrowTypeStart: e.arrowTypeStart,\n arrowTypeEnd: e.arrowTypeEnd,\n }));\n applyLineJumpsToSvg(groups.edgePaths, edgeGeometries, {\n enabled: true,\n jumpRadius: 6,\n jumpStyle,\n });\n }\n}\n\nfunction positionEdgeLabel(edge: any, paths: any) {\n const path = paths?.updatedPath ?? paths?.originalPath;\n const siteConfig = getConfig();\n const { subGraphTitleTotalMargin } = getSubGraphTitleMargins({\n flowchart: siteConfig.flowchart ?? {},\n });\n if (edge.label) {\n const el = edgeLabels.get(edge.id);\n let x = edge.x;\n let y = edge.y;\n if (path) {\n const pos = utils.calcLabelPosition(path);\n log.debug(\n 'Moving label ' + edge.label + ' from (',\n x,\n ',',\n y,\n ') to (',\n pos.x,\n ',',\n pos.y,\n ') abc88'\n );\n if (paths) {\n x = pos.x;\n y = pos.y;\n }\n }\n el.attr('transform', `translate(${x}, ${y + subGraphTitleTotalMargin / 2})`);\n }\n\n if (edge?.startLabelLeft) {\n const el = terminalLabels.get(edge.id).startLeft;\n let x = edge?.x;\n let y = edge?.y;\n if (path) {\n const pos = utils.calcTerminalLabelPosition(edge.arrowTypeStart ? 10 : 0, 'start_left', path);\n x = pos.x;\n y = pos.y;\n }\n el.attr('transform', `translate(${x}, ${y})`);\n }\n if (edge.startLabelRight) {\n const el = terminalLabels.get(edge.id).startRight;\n let x = edge.x;\n let y = edge.y;\n if (path) {\n const pos = utils.calcTerminalLabelPosition(\n edge.arrowTypeStart ? 10 : 0,\n 'start_right',\n path\n );\n x = pos.x;\n y = pos.y;\n }\n el.attr('transform', `translate(${x}, ${y})`);\n }\n if (edge.endLabelLeft) {\n const el = terminalLabels.get(edge.id).endLeft;\n let x = edge.x;\n let y = edge.y;\n if (path) {\n const pos = utils.calcTerminalLabelPosition(edge.arrowTypeEnd ? 10 : 0, 'end_left', path);\n x = pos.x;\n y = pos.y;\n }\n el.attr('transform', `translate(${x}, ${y})`);\n }\n if (edge.endLabelRight) {\n const el = terminalLabels.get(edge.id).endRight;\n let x = edge.x;\n let y = edge.y;\n if (path) {\n const pos = utils.calcTerminalLabelPosition(edge.arrowTypeEnd ? 10 : 0, 'end_right', path);\n x = pos.x;\n y = pos.y;\n }\n el.attr('transform', `translate(${x}, ${y})`);\n }\n}\n", "import type {\n LayoutData,\n Node as MermaidNode,\n Edge as MermaidEdge,\n ClusterNode,\n} from '../../types.js';\n\nexport type Layout = LayoutData;\nexport type Node = MermaidNode;\nexport type NodeId = Node['id'];\nexport type EdgeId = MermaidEdge['id'];\n\nexport interface EdgeRef {\n id: EdgeId;\n src: NodeId;\n dst: NodeId;\n weight?: number;\n ref: MermaidEdge;\n}\n\nexport interface Graph {\n nodes: NodeId[];\n edges: EdgeRef[];\n layout: Layout;\n nodeById: Map<NodeId, Node>;\n}\n\nexport interface Layering {\n layers: NodeId[][];\n rankOf: Record<NodeId, number>;\n dummy?: Set<NodeId>;\n}\n\nexport interface OrderedLayers {\n layers: NodeId[][];\n}\n\nexport interface Coordinates {\n x: Record<NodeId, number>;\n y: Record<NodeId, number>;\n}\n\nexport type Edge = EdgeRef;\n\nexport const DEFAULT_SWIMLANE_ID = '__swimlane_default__';\n\nexport interface WriteBackOptions {\n layerGap?: number;\n nodeGap?: number;\n}\n\n// Captured swimlane fixtures use 21px as the stable single-line label height.\nconst TOP_LANE_TITLE_BAND_HEIGHT = 21;\nconst MIN_TOP_LANE_HORIZONTAL_PADDING = 20;\n\nfunction topLaneHorizontalPadding(lane: Node): number {\n return Math.max(lane.padding ?? MIN_TOP_LANE_HORIZONTAL_PADDING, MIN_TOP_LANE_HORIZONTAL_PADDING);\n}\n\nfunction assignTopLaneTitleRect(lane: Node): void {\n const { x, y, width, height } = lane;\n const contentTop = (lane as { swimlaneContentTop?: unknown }).swimlaneContentTop;\n if (\n typeof x !== 'number' ||\n typeof y !== 'number' ||\n typeof width !== 'number' ||\n typeof height !== 'number' ||\n typeof contentTop !== 'number' ||\n !Number.isFinite(x) ||\n !Number.isFinite(y) ||\n !Number.isFinite(width) ||\n !Number.isFinite(height) ||\n !Number.isFinite(contentTop) ||\n width <= 0 ||\n height <= 0\n ) {\n delete lane.groupTitleRect;\n return;\n }\n\n const top = y - height / 2;\n const headerBottom = Math.min(contentTop, y + height / 2);\n const titleHeight = Math.min(TOP_LANE_TITLE_BAND_HEIGHT, Math.max(0, headerBottom - top));\n const bottom = top + titleHeight;\n if (bottom <= top) {\n delete lane.groupTitleRect;\n return;\n }\n\n lane.groupTitleRect = {\n left: x - width / 2,\n right: x + width / 2,\n top,\n bottom,\n };\n}\n\nexport function prepareLayoutForSwimlanes(layout: LayoutData): void {\n const direction = (layout as any).direction;\n const nodes = (layout.nodes ??= []);\n for (const node of layout.nodes ?? []) {\n if (node.isGroup && !node.parentId) {\n node.shape = 'swimlane';\n if (direction) {\n (node as any).direction = direction;\n }\n }\n }\n\n const looseNodes = nodes.filter((node) => !node.isGroup && !node.parentId);\n if (looseNodes.length === 0) {\n return;\n }\n\n let defaultLane = nodes.find((node) => node.id === DEFAULT_SWIMLANE_ID);\n if (!defaultLane) {\n defaultLane = {\n id: DEFAULT_SWIMLANE_ID,\n label: '',\n isGroup: true,\n shape: 'swimlane',\n padding: 20,\n ...(direction ? { direction } : {}),\n } as ClusterNode;\n nodes.push(defaultLane);\n } else if (defaultLane.isGroup) {\n defaultLane.shape = 'swimlane';\n if (direction) {\n (defaultLane as any).direction = direction;\n }\n }\n\n for (const node of looseNodes) {\n node.parentId = DEFAULT_SWIMLANE_ID;\n }\n}\n\nexport function toGraphView(layout: LayoutData): Graph {\n const nodeById = new Map<NodeId, Node>();\n for (const n of layout.nodes ?? []) {\n nodeById.set(n.id, n);\n }\n\n const edges: EdgeRef[] = [];\n for (const e of layout.edges ?? []) {\n const src = typeof e.start === 'string' ? e.start : undefined;\n const dst = typeof e.end === 'string' ? e.end : undefined;\n if (!src || !dst) {\n continue;\n }\n // Exclude labelled originals from Sugiyama: their routing is carried by\n // the two layout-only virtual edges A\u2192label and label\u2192B, which create the\n // correct layer/ordering constraints. Including the original as well would\n // double-count rank pressure and inflate crossing penalties.\n if ((e as MermaidEdge & { labelNodeId?: string }).labelNodeId) {\n continue;\n }\n edges.push({ id: e.id, src, dst, ref: e });\n }\n\n const allNodes = layout.nodes ?? [];\n const groupNodes = allNodes.filter((n) => n.isGroup);\n const nonGroupNodes = allNodes.filter((n) => !n.isGroup);\n\n const nodesInGroupOrder = [...groupNodes].reverse();\n const nodes: NodeId[] = [...nodesInGroupOrder, ...nonGroupNodes].map((n) => n.id);\n return { nodes, edges, layout, nodeById };\n}\n\nexport function writeBackToLayoutData(\n g: Graph,\n ordered: OrderedLayers,\n coords: Coordinates,\n opts?: WriteBackOptions\n): void {\n const { layout } = g;\n const nodeMap = g.nodeById;\n const layerGap = opts?.layerGap ?? 100;\n const nodeGap = opts?.nodeGap ?? 40;\n\n let layerIndex = 0;\n for (const layer of ordered.layers) {\n let orderIndex = 0;\n for (const id of layer) {\n const node = nodeMap.get(id);\n if (!node) {\n orderIndex++;\n continue;\n }\n node.layer = layerIndex;\n node.order = orderIndex;\n const x = coords.x[id] ?? orderIndex * nodeGap;\n const y = coords.y[id] ?? layerIndex * layerGap;\n node.x = x;\n node.y = y;\n orderIndex++;\n }\n layerIndex++;\n }\n\n const allNodes = layout.nodes ?? [];\n const groupBounds = new Map<NodeId, { minX: number; maxX: number; minY: number; maxY: number }>();\n const topLevelGroups: Node[] = [];\n for (const group of allNodes) {\n if (!group?.isGroup) {\n continue;\n }\n if (!group.parentId) {\n topLevelGroups.push(group);\n }\n const children = allNodes.filter((n) => n.parentId === group.id);\n let minX = Infinity;\n let maxX = -Infinity;\n let minY = Infinity;\n let maxY = -Infinity;\n for (const child of children) {\n const cx = child.x ?? coords.x[child.id];\n const cy = child.y ?? coords.y[child.id];\n const cw = child.width ?? 0;\n const ch = child.height ?? 0;\n if (cx != null && cy != null) {\n minX = Math.min(minX, cx - cw / 2);\n maxX = Math.max(maxX, cx + cw / 2);\n minY = Math.min(minY, cy - ch / 2);\n maxY = Math.max(maxY, cy + ch / 2);\n }\n }\n if (minX === Infinity || minY === Infinity) {\n group.x = group.x ?? 0;\n group.y = group.y ?? 0;\n group.width = group.width ?? 0;\n group.height = group.height ?? 0;\n } else {\n const pad = group.padding ?? 20;\n const horizontalPad = group.parentId ? pad : 2 * topLaneHorizontalPadding(group);\n const verticalPad = pad;\n const w = Math.max(0, maxX - minX) + horizontalPad;\n const h = Math.max(0, maxY - minY) + verticalPad;\n const cx = (minX + maxX) / 2;\n const cy = (minY + maxY) / 2;\n group.x = cx;\n group.y = cy;\n group.width = w;\n group.height = h;\n groupBounds.set(group.id, { minX, maxX, minY, maxY });\n }\n }\n\n if (topLevelGroups.length > 0 && groupBounds.size > 0) {\n let globalMinY = Infinity;\n let globalMaxY = -Infinity;\n let maxPad = 0;\n for (const lane of topLevelGroups) {\n const pad = lane.padding ?? 20;\n if (pad > maxPad) {\n maxPad = pad;\n }\n const b = groupBounds.get(lane.id);\n if (!b) {\n continue;\n }\n globalMinY = Math.min(globalMinY, b.minY);\n globalMaxY = Math.max(globalMaxY, b.maxY);\n }\n if (globalMinY !== Infinity && globalMaxY !== -Infinity) {\n const contentHeight = Math.max(0, globalMaxY - globalMinY);\n const minHeaderMargin = 36;\n const verticalMargin = Math.max(maxPad, minHeaderMargin);\n const laneHeight = contentHeight + 2 * verticalMargin;\n const centerY = (globalMinY + globalMaxY) / 2;\n for (const lane of topLevelGroups) {\n lane.y = centerY;\n lane.height = laneHeight;\n (lane as any).swimlaneContentTop = globalMinY;\n }\n\n const sortedLanes = [...topLevelGroups].sort((a, b) => {\n const ax = a.x ?? 0;\n const bx = b.x ?? 0;\n return ax - bx;\n });\n\n const laneIds: NodeId[] = [];\n const centers: number[] = [];\n const baseWidths: number[] = [];\n\n for (const lane of sortedLanes) {\n const b = groupBounds.get(lane.id);\n if (!b) {\n continue;\n }\n const contentWidth = Math.max(0, b.maxX - b.minX) + 2 * topLaneHorizontalPadding(lane);\n const cx = (b.minX + b.maxX) / 2;\n laneIds.push(lane.id);\n centers.push(cx);\n baseWidths.push(contentWidth);\n }\n\n const count = laneIds.length;\n if (count > 0) {\n const laneWidths = new Map<NodeId, number>();\n\n if (count === 1) {\n laneWidths.set(laneIds[0], baseWidths[0]);\n } else {\n const d: number[] = [];\n for (let i = 0; i < count - 1; i++) {\n d.push(centers[i + 1] - centers[i]);\n }\n\n const u: number[] = new Array(count);\n u[0] = 0;\n for (let i = 0; i < count - 1; i++) {\n u[i + 1] = 2 * d[i] - u[i];\n }\n\n let lowerBound = 0;\n let upperBound = Number.POSITIVE_INFINITY;\n for (let i = 0; i < count; i++) {\n const baseW = baseWidths[i];\n if (i % 2 === 0) {\n lowerBound = Math.max(lowerBound, baseW - u[i]);\n } else {\n upperBound = Math.min(upperBound, u[i] - baseW);\n }\n }\n\n let x = lowerBound;\n if (lowerBound <= upperBound) {\n x = (lowerBound + upperBound) / 2;\n } else {\n x = lowerBound;\n }\n\n for (let i = 0; i < count; i++) {\n const w = u[i] + (i % 2 === 0 ? x : -x);\n const finalWidth = Math.max(baseWidths[i], w);\n laneWidths.set(laneIds[i], finalWidth);\n }\n }\n\n for (const lane of topLevelGroups) {\n const w = laneWidths.get(lane.id);\n if (w != null) {\n lane.width = w;\n }\n assignTopLaneTitleRect(lane);\n }\n }\n }\n }\n}\n", "/**\n * Edge Label Nodes Transformation (label-as-waypoint variant)\n *\n * For each labelled edge, this transform creates an `edge-label-*` node that\n * participates in the Sugiyama layout (so the label text gets a deterministic\n * position in a lane). Unlike the older split-edge model, it leaves the\n * original labelled edge in place and stamps `labelNodeId` on it \u2014 the router\n * uses that stamp to thread the original edge's single polyline through the\n * label node's center.\n *\n * Two `isLayoutOnly` virtual edges (A\u2192label, label\u2192B) are appended to the\n * layout so that Sugiyama's layering and ordering honour the label's position\n * between source and target. They are never routed or rendered: the router and\n * renderer skip any edge flagged with `isLayoutOnly`.\n */\n\nimport type { LayoutData, Node, Edge, NonClusterNode } from '../../types.js';\nimport { log } from '../../../logger.js';\n\nconst EDGE_LABEL_LOG_PREFIX = '[EdgeLabelNodes]';\n\n/**\n * Transforms edges with labels into label nodes + layout-only virtual edges.\n *\n * For each edge with a label:\n * 1. Creates a label node with the label text.\n * 2. Assigns the label node to the source or target lane (cross-lane edges\n * prefer the target lane for tighter routing).\n * 3. Stamps `labelNodeId` on the original edge.\n * 4. Appends two `isLayoutOnly: true` virtual edges (A\u2192label, label\u2192B) so\n * Sugiyama places the label between source and target. The router skips\n * these; only the original edge is routed (threading through the label\n * node's center).\n *\n * @param data - The layout data to transform\n * @returns The transformed layout data with label nodes and virtual edges\n */\nexport function createEdgeLabelNodes(data: LayoutData): LayoutData {\n const nodesToAdd: NonClusterNode[] = [];\n const layoutOnlyEdges: Edge[] = [];\n\n const nodeById = new Map<string, Node>();\n for (const node of data.nodes) {\n nodeById.set(node.id, node);\n }\n\n for (const edge of data.edges) {\n if (!edge.label || edge.label.length === 0) {\n continue;\n }\n if ((edge as Edge & { isLayoutOnly?: boolean }).isLayoutOnly) {\n continue;\n }\n // Guard against double-processing if the caller invokes us twice.\n if ((edge as Edge & { labelNodeId?: string }).labelNodeId) {\n continue;\n }\n\n const sourceNode = edge.start ? nodeById.get(edge.start) : undefined;\n const targetNode = edge.end ? nodeById.get(edge.end) : undefined;\n\n if (!sourceNode || !targetNode) {\n log.warn(EDGE_LABEL_LOG_PREFIX, `Edge ${edge.id} has missing source or target node`);\n continue;\n }\n\n const labelNodeId = `edge-label-${edge.start}-${edge.end}-${edge.id}`;\n\n // For cross-lane edges, assign to the target lane for better routing:\n // it keeps the label closer to where the edge is heading and avoids long\n // detours back to the source lane.\n const isCrossLane = sourceNode.parentId !== targetNode.parentId;\n const labelLane = isCrossLane ? targetNode.parentId : sourceNode.parentId;\n\n const labelNode: NonClusterNode = {\n id: labelNodeId,\n label: edge.label,\n edgeStart: edge.start ?? '',\n edgeEnd: edge.end ?? '',\n shape: 'labelRect',\n width: 0, // populated when rendered / applied from fixture\n height: 0,\n isEdgeLabel: true,\n isDummy: true,\n parentId: labelLane,\n isGroup: false,\n labelStyle: Array.isArray(edge.labelStyle) ? edge.labelStyle[0] : (edge.labelStyle ?? ''),\n ...(sourceNode.dir ? { dir: sourceNode.dir } : {}),\n };\n\n nodesToAdd.push(labelNode);\n\n // Stamp the original edge so the router can decompose routing through the\n // label's center when producing a single polyline.\n (edge as Edge & { labelNodeId?: string }).labelNodeId = labelNodeId;\n\n // Ownership of the label text moves to the label node. Clear the label\n // off the original edge so the edge renderer does not draw it a second\n // time alongside the label node's own text.\n edge.label = undefined;\n (edge as Edge & { text?: unknown }).text = undefined;\n\n // Layout-only virtual edges: Sugiyama uses these to place the label node\n // between source and target. They are not routed or rendered \u2014 consumers\n // must skip any edge with `isLayoutOnly: true`.\n const toLabelVirtual: Edge = {\n id: `${edge.id}-to-label`,\n start: edge.start,\n end: labelNodeId,\n type: 'normal',\n isLayoutOnly: true,\n } as unknown as Edge;\n const fromLabelVirtual: Edge = {\n id: `${edge.id}-from-label`,\n start: labelNodeId,\n end: edge.end,\n type: 'normal',\n isLayoutOnly: true,\n } as unknown as Edge;\n\n layoutOnlyEdges.push(toLabelVirtual, fromLabelVirtual);\n }\n\n const newNodes = [...data.nodes, ...nodesToAdd];\n const newEdges = [...data.edges, ...layoutOnlyEdges];\n\n return {\n ...data,\n nodes: newNodes,\n edges: newEdges,\n };\n}\n", "const EPS = 1e-3;\n\nexport interface Point {\n x: number;\n y: number;\n}\n\nexport type RectSide = 'top' | 'bottom' | 'left' | 'right';\n\nexport interface RectBounds {\n left: number;\n r