@zombie-mermaid/ascii-renderer 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/LICENSE +22 -0
  2. package/dist/index.cjs +8 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +146 -0
  5. package/dist/index.d.ts +146 -0
  6. package/dist/index.js +5392 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +36 -0
  9. package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
  10. package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
  11. package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
  12. package/src/__tests__/ascii-canvas-write.test.ts +132 -0
  13. package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
  14. package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
  15. package/src/__tests__/ascii-cjk-width.test.ts +150 -0
  16. package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
  17. package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
  18. package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
  19. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
  20. package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
  21. package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
  22. package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
  23. package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
  24. package/src/__tests__/ascii-class-padding.test.ts +104 -0
  25. package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
  26. package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
  27. package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +169 -0
  28. package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
  29. package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
  30. package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
  31. package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
  32. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
  33. package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
  34. package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
  35. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
  36. package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
  37. package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
  38. package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
  39. package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
  40. package/src/__tests__/ascii-edge-styles.test.ts +149 -0
  41. package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
  42. package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
  43. package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
  44. package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
  45. package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
  46. package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
  47. package/src/__tests__/ascii-er-padding.test.ts +114 -0
  48. package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
  49. package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
  50. package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
  51. package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
  52. package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
  53. package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
  54. package/src/__tests__/ascii-lane-search.test.ts +207 -0
  55. package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
  56. package/src/__tests__/ascii-multiline.test.ts +288 -0
  57. package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
  58. package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
  59. package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
  60. package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
  61. package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
  62. package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
  63. package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
  64. package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
  65. package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
  66. package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
  67. package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
  68. package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
  69. package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
  70. package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
  71. package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
  72. package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
  73. package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
  74. package/src/__tests__/ascii-shapes-special.test.ts +344 -0
  75. package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
  76. package/src/__tests__/ascii-shapes-state.test.ts +224 -0
  77. package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
  78. package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
  79. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
  80. package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
  81. package/src/__tests__/ascii-territory-unit.test.ts +219 -0
  82. package/src/__tests__/ascii-validate.test.ts +220 -0
  83. package/src/__tests__/ascii.test.ts +325 -0
  84. package/src/__tests__/class-arrow-directions.test.ts +505 -0
  85. package/src/__tests__/draw-lines.test.ts +93 -0
  86. package/src/__tests__/edge-cell-styles.test.ts +278 -0
  87. package/src/__tests__/grid-occupancy.test.ts +240 -0
  88. package/src/__tests__/helpers/ascii-form.ts +142 -0
  89. package/src/__tests__/helpers/terminal-display-width.ts +74 -0
  90. package/src/__tests__/pathfinder.test.ts +239 -0
  91. package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
  92. package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
  93. package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
  94. package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
  95. package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
  96. package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
  97. package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
  98. package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
  99. package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
  100. package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
  101. package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
  102. package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
  103. package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
  104. package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
  105. package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
  106. package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
  107. package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
  108. package/src/__tests__/testdata/ascii/comments.txt +23 -0
  109. package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
  110. package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
  111. package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
  112. package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
  113. package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
  114. package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
  115. package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
  116. package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
  117. package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
  118. package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
  119. package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
  120. package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
  121. package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
  122. package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
  123. package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
  124. package/src/__tests__/testdata/ascii/single_node.txt +8 -0
  125. package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
  126. package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
  127. package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
  128. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
  129. package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
  130. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
  131. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
  132. package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
  133. package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
  134. package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
  135. package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
  136. package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
  137. package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
  138. package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
  139. package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
  140. package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
  141. package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
  142. package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
  143. package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
  144. package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
  145. package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
  146. package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
  147. package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
  148. package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
  149. package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
  150. package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
  151. package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
  152. package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
  153. package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
  154. package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
  155. package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
  156. package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
  157. package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
  158. package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
  159. package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
  160. package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
  161. package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
  162. package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
  163. package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
  164. package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
  165. package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
  166. package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
  167. package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
  168. package/src/__tests__/testdata/unicode/comments.txt +23 -0
  169. package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
  170. package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
  171. package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
  172. package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
  173. package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
  174. package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
  175. package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
  176. package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
  177. package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
  178. package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
  179. package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
  180. package/src/__tests__/testdata/unicode/single_node.txt +8 -0
  181. package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
  182. package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
  183. package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
  184. package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
  185. package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
  186. package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
  187. package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
  188. package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
  189. package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
  190. package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
  191. package/src/__tests__/xychart-ascii.test.ts +376 -0
  192. package/src/ansi.ts +490 -0
  193. package/src/canvas.ts +757 -0
  194. package/src/class-diagram.ts +2001 -0
  195. package/src/converter.ts +446 -0
  196. package/src/coords.ts +58 -0
  197. package/src/display-width.ts +151 -0
  198. package/src/draw-arrows.ts +593 -0
  199. package/src/draw-boxes.ts +267 -0
  200. package/src/draw-bundles.ts +611 -0
  201. package/src/draw-lines.ts +174 -0
  202. package/src/draw-subgraphs.ts +108 -0
  203. package/src/draw.ts +350 -0
  204. package/src/edge-bundling.ts +435 -0
  205. package/src/edge-cell-styles.ts +209 -0
  206. package/src/edge-routing.ts +1070 -0
  207. package/src/er-diagram.ts +1488 -0
  208. package/src/flowchart.ts +94 -0
  209. package/src/grid-occupancy.ts +234 -0
  210. package/src/grid.ts +1309 -0
  211. package/src/hyperlinks.ts +248 -0
  212. package/src/index.ts +163 -0
  213. package/src/lane-search.ts +68 -0
  214. package/src/multiline-utils.ts +82 -0
  215. package/src/pathfinder.ts +448 -0
  216. package/src/registry.ts +88 -0
  217. package/src/sequence.ts +1318 -0
  218. package/src/shapes/circle.ts +31 -0
  219. package/src/shapes/corners.ts +273 -0
  220. package/src/shapes/diamond.ts +31 -0
  221. package/src/shapes/hexagon.ts +35 -0
  222. package/src/shapes/index.ts +123 -0
  223. package/src/shapes/rectangle.ts +199 -0
  224. package/src/shapes/rounded.ts +31 -0
  225. package/src/shapes/special.ts +360 -0
  226. package/src/shapes/stadium.ts +122 -0
  227. package/src/shapes/state.ts +204 -0
  228. package/src/shapes/types.ts +78 -0
  229. package/src/territory.ts +136 -0
  230. package/src/types.ts +454 -0
  231. package/src/validate.ts +189 -0
  232. package/src/xychart.ts +1085 -0
package/src/grid.ts ADDED
@@ -0,0 +1,1309 @@
1
+ // ============================================================================
2
+ // ASCII renderer — grid-based layout
3
+ //
4
+ // Ported from AlexanderGrooff/mermaid-ascii cmd/graph.go + cmd/mapping_node.go.
5
+ // Places nodes on a logical grid, computes column/row sizes,
6
+ // converts grid coordinates to character-level drawing coordinates,
7
+ // and handles subgraph bounding boxes.
8
+ // ============================================================================
9
+
10
+ import type {
11
+ GridCoord,
12
+ DrawingCoord,
13
+ Direction,
14
+ AsciiEdge,
15
+ AsciiGraph,
16
+ AsciiNode,
17
+ AsciiSubgraph,
18
+ } from './types.ts'
19
+ import { gridKey, requireGridCoord } from './types.ts'
20
+ import { setCanvasSizeToGrid, setRoleCanvasSizeToGrid } from './canvas.ts'
21
+ import {
22
+ determinePath,
23
+ determineLabelLine,
24
+ assignParallelEdgeLanes,
25
+ } from './edge-routing.ts'
26
+ import { analyzeEdgeBundles, processBundles } from './edge-bundling.ts'
27
+ import { createPathBudget } from './pathfinder.ts'
28
+ import {
29
+ isBlockFree,
30
+ placeBlock,
31
+ cloneGrid,
32
+ NODE_BLOCK_SIZE,
33
+ type Grid,
34
+ } from './grid-occupancy.ts'
35
+ import {
36
+ createEdgeCellStyles,
37
+ claimPathCells,
38
+ findStyleConflict,
39
+ createEdgeCellOwners,
40
+ claimPathOwners,
41
+ findUnrelatedOverlap,
42
+ type EdgeCellStyles,
43
+ type EdgeCellOwners,
44
+ } from './edge-cell-styles.ts'
45
+ import { drawBox } from './draw.ts'
46
+ import { getShapeDimensions } from './shapes/index.ts'
47
+ import { splitLines } from './multiline-utils.ts'
48
+ import { displayWidth } from './display-width.ts'
49
+
50
+ // `requireGridCoord` is defined in types.ts (a pure predicate over
51
+ // AsciiNode with no grid-state dependency) and re-exported here so the two
52
+ // existing call sites that import it from this module (edge-routing.ts,
53
+ // edge-bundling.ts) don't need to change.
54
+ export { requireGridCoord }
55
+
56
+ // ============================================================================
57
+ // Grid coordinate → drawing coordinate conversion
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Convert a grid coordinate to a drawing (character) coordinate.
62
+ * Sums column widths up to the target column, and row heights up to the target row,
63
+ * then centers within the cell.
64
+ */
65
+ export function gridToDrawingCoord(
66
+ graph: AsciiGraph,
67
+ c: GridCoord,
68
+ dir?: Direction,
69
+ ): DrawingCoord {
70
+ const target: GridCoord = dir ? { x: c.x + dir.x, y: c.y + dir.y } : c
71
+
72
+ let x = 0
73
+ for (let col = 0; col < target.x; col++) {
74
+ x += graph.columnWidth.get(col) ?? 0
75
+ }
76
+
77
+ let y = 0
78
+ for (let row = 0; row < target.y; row++) {
79
+ y += graph.rowHeight.get(row) ?? 0
80
+ }
81
+
82
+ const colW = graph.columnWidth.get(target.x) ?? 0
83
+ const rowH = graph.rowHeight.get(target.y) ?? 0
84
+ return {
85
+ x: x + Math.floor(colW / 2) + graph.offsetX,
86
+ y: y + Math.floor(rowH / 2) + graph.offsetY,
87
+ }
88
+ }
89
+
90
+ /** Convert a path of grid coords to drawing coords. */
91
+ export function lineToDrawing(
92
+ graph: AsciiGraph,
93
+ line: GridCoord[],
94
+ ): DrawingCoord[] {
95
+ return line.map((c) => gridToDrawingCoord(graph, c))
96
+ }
97
+
98
+ // ============================================================================
99
+ // Node placement on the grid
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Reserve a 3x3 block in the grid for a node.
104
+ * If the requested position is occupied, recursively shift by 4 grid units
105
+ * (in the perpendicular direction based on effective direction) until a free spot is found.
106
+ *
107
+ * @param effectiveDir - Optional direction override. If not provided, uses the node's
108
+ * effective direction (subgraph direction if in a subgraph with override,
109
+ * otherwise graph direction).
110
+ */
111
+ export function reserveSpotInGrid(
112
+ graph: AsciiGraph,
113
+ node: AsciiNode,
114
+ requested: GridCoord,
115
+ effectiveDir?: 'LR' | 'TD',
116
+ ): GridCoord {
117
+ // Determine direction for collision handling
118
+ const dir = effectiveDir ?? getEffectiveDirection(graph, node)
119
+
120
+ if (!isBlockFree(graph.grid, requested, NODE_BLOCK_SIZE)) {
121
+ // Collision — shift perpendicular to main flow direction
122
+ if (dir === 'LR') {
123
+ return reserveSpotInGrid(
124
+ graph,
125
+ node,
126
+ { x: requested.x, y: requested.y + 4 },
127
+ dir,
128
+ )
129
+ } else {
130
+ return reserveSpotInGrid(
131
+ graph,
132
+ node,
133
+ { x: requested.x + 4, y: requested.y },
134
+ dir,
135
+ )
136
+ }
137
+ }
138
+
139
+ placeBlock(graph.grid, requested, NODE_BLOCK_SIZE)
140
+
141
+ node.gridCoord = requested
142
+ return requested
143
+ }
144
+
145
+ // ============================================================================
146
+ // Column width / row height computation
147
+ // ============================================================================
148
+
149
+ /**
150
+ * Set column widths and row heights for a node's 3x3 grid block.
151
+ * Each node occupies 3 columns (border, content, border) and 3 rows.
152
+ * Uses shape-aware dimensions to properly size non-rectangular shapes.
153
+ */
154
+ export function setColumnWidth(graph: AsciiGraph, node: AsciiNode): void {
155
+ const gc = requireGridCoord(node)
156
+ const padding = graph.config.boxBorderPadding
157
+
158
+ // Get shape-aware dimensions
159
+ const shapeDims = getShapeDimensions(node.shape, node.displayLabel, {
160
+ useAscii: graph.config.useAscii,
161
+ padding,
162
+ })
163
+
164
+ // Use shape-provided grid dimensions
165
+ const colWidths = shapeDims.gridColumns
166
+ const rowHeights = shapeDims.gridRows
167
+
168
+ for (let idx = 0; idx < colWidths.length; idx++) {
169
+ const xCoord = gc.x + idx
170
+ const current = graph.columnWidth.get(xCoord) ?? 0
171
+ graph.columnWidth.set(xCoord, Math.max(current, colWidths[idx]!))
172
+ }
173
+
174
+ for (let idx = 0; idx < rowHeights.length; idx++) {
175
+ const yCoord = gc.y + idx
176
+ const current = graph.rowHeight.get(yCoord) ?? 0
177
+ graph.rowHeight.set(yCoord, Math.max(current, rowHeights[idx]!))
178
+ }
179
+
180
+ // Padding column/row before the node (spacing between nodes)
181
+ if (gc.x > 0) {
182
+ const current = graph.columnWidth.get(gc.x - 1) ?? 0
183
+ graph.columnWidth.set(gc.x - 1, Math.max(current, graph.config.paddingX))
184
+ }
185
+
186
+ if (gc.y > 0) {
187
+ let basePadding = graph.config.paddingY
188
+ // Extra vertical padding for nodes with incoming edges from outside their subgraph
189
+ if (hasIncomingEdgeFromOutsideSubgraph(graph, node)) {
190
+ const subgraphOverhead = 4
191
+ basePadding += subgraphOverhead
192
+ }
193
+ const current = graph.rowHeight.get(gc.y - 1) ?? 0
194
+ graph.rowHeight.set(gc.y - 1, Math.max(current, basePadding))
195
+ }
196
+ }
197
+
198
+ /** Ensure grid has width/height entries for all cells along an edge path. */
199
+ export function increaseGridSizeForPath(
200
+ graph: AsciiGraph,
201
+ path: GridCoord[],
202
+ ): void {
203
+ for (const c of path) {
204
+ if (!graph.columnWidth.has(c.x)) {
205
+ graph.columnWidth.set(c.x, Math.floor(graph.config.paddingX / 2))
206
+ }
207
+ if (!graph.rowHeight.has(c.y)) {
208
+ graph.rowHeight.set(c.y, Math.floor(graph.config.paddingY / 2))
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Cap on re-route attempts for a single edge's cross-style conflicts.
215
+ *
216
+ * `determinePath`'s A* attempts respect the blocked cells added below, but
217
+ * its Case-4 direct-fallback (used when both A* attempts fail outright)
218
+ * draws a straight line ignoring occupancy entirely — so a pathological
219
+ * layout where every route is blocked could keep "finding" the same
220
+ * conflict forever. This bounds that to a handful of tries; if a genuine
221
+ * conflict survives them, the edge keeps its last-routed (still
222
+ * overlapping) path rather than looping — the same "graceful degradation
223
+ * over a hard failure" the render-wide `PathBudget` already applies to A*
224
+ * itself.
225
+ */
226
+ const MAX_STYLE_CONFLICT_REROUTES = 8
227
+
228
+ /**
229
+ * After `edge` has been routed, check whether its path crosses a cell
230
+ * already claimed by a *different*-style edge, or by an unrelated edge's
231
+ * run of cells long enough to read as one continuous connector (see
232
+ * edge-cell-styles.ts's two conflict checks) and, if so, re-route it around
233
+ * the conflicting cell(s).
234
+ *
235
+ * Works by temporarily adding the conflicting cell to `graph.grid` — the
236
+ * same occupancy map A* already treats node cells as blocked through — so
237
+ * `determinePath`'s A* search avoids it on the next attempt, then removing
238
+ * that temporary block again once this edge is done (it must not
239
+ * permanently block the cell for other, unrelated edges; only these two
240
+ * conflict shapes are meant to be avoided, not all future overlap).
241
+ *
242
+ * `nodeOnlyGrid` — not `graph.grid` — is what gets passed to
243
+ * `findStyleConflict`/`findUnrelatedOverlap`'s "is this cell node-owned, and
244
+ * therefore not a real conflict" check. This must be a separate,
245
+ * frozen-at-node-placement-time grid: `graph.grid` gets `add`ed to right
246
+ * below for A*'s benefit, and if the *conflict check* used that same live
247
+ * grid, a cell temporarily blocked on attempt 1 would look node-occupied on
248
+ * attempt 2 — so if A*'s direct-fallback (which ignores occupancy) routes
249
+ * right back through it, the loop would wrongly see "no conflict" and stop,
250
+ * leaving the two edges still overlapping there. See
251
+ * ascii-edge-cross-style-overlap.test.ts's regression test for this exact
252
+ * scenario.
253
+ */
254
+ function rerouteAroundStyleConflicts(
255
+ graph: AsciiGraph,
256
+ edge: AsciiEdge,
257
+ cellStyles: EdgeCellStyles,
258
+ cellOwners: EdgeCellOwners,
259
+ nodeOnlyGrid: Grid,
260
+ ): void {
261
+ const temporarilyBlocked: GridCoord[] = []
262
+ try {
263
+ for (let i = 0; i < MAX_STYLE_CONFLICT_REROUTES; i++) {
264
+ const conflict =
265
+ findStyleConflict(nodeOnlyGrid, cellStyles, edge.path, edge.style) ??
266
+ findUnrelatedOverlap(nodeOnlyGrid, cellOwners, edge.path, edge)
267
+ if (!conflict) return
268
+ graph.grid.add(gridKey(conflict))
269
+ temporarilyBlocked.push(conflict)
270
+ determinePath(graph, edge)
271
+ }
272
+ } finally {
273
+ for (const cell of temporarilyBlocked) graph.grid.delete(gridKey(cell))
274
+ }
275
+ }
276
+
277
+ // ============================================================================
278
+ // Subgraph helpers
279
+ // ============================================================================
280
+
281
+ function isNodeInAnySubgraph(graph: AsciiGraph, node: AsciiNode): boolean {
282
+ return graph.subgraphs.some((sg) => sg.nodes.includes(node))
283
+ }
284
+
285
+ /**
286
+ * Get the innermost subgraph that directly contains this node.
287
+ * Returns null if node is not in any subgraph.
288
+ */
289
+ export function getNodeSubgraph(
290
+ graph: AsciiGraph,
291
+ node: AsciiNode,
292
+ ): AsciiSubgraph | null {
293
+ // Find the innermost (most deeply nested) subgraph containing the node
294
+ let innermost: AsciiSubgraph | null = null
295
+ for (const sg of graph.subgraphs) {
296
+ if (sg.nodes.includes(node)) {
297
+ // Check if this subgraph is deeper (more nested) than current innermost
298
+ if (!innermost || isAncestorOrSelf(innermost, sg)) {
299
+ innermost = sg
300
+ }
301
+ }
302
+ }
303
+ return innermost
304
+ }
305
+
306
+ /** Check if `candidate` is the same as or an ancestor of `target`. */
307
+ function isAncestorOrSelf(
308
+ candidate: AsciiSubgraph,
309
+ target: AsciiSubgraph,
310
+ ): boolean {
311
+ let current: AsciiSubgraph | null = target
312
+ while (current !== null) {
313
+ if (current === candidate) return true
314
+ current = current.parent
315
+ }
316
+ return false
317
+ }
318
+
319
+ /**
320
+ * Get the outermost (top-level, unnested) subgraph ancestor for a node.
321
+ * Returns null if the node isn't in any subgraph.
322
+ *
323
+ * Used to group nodes for bounding-box-disjointness purposes: sibling
324
+ * subgraphs nested under different top-level subgraphs are unrelated and
325
+ * must never be allowed to overlap (#90), while a subgraph's own nested
326
+ * children are already folded into its box via calculateSubgraphBoundingBox.
327
+ */
328
+ function getTopLevelSubgraph(
329
+ graph: AsciiGraph,
330
+ node: AsciiNode,
331
+ ): AsciiSubgraph | null {
332
+ const sg = getNodeSubgraph(graph, node)
333
+ if (!sg) return null
334
+ let top = sg
335
+ while (top.parent) top = top.parent
336
+ return top
337
+ }
338
+
339
+ /** Recursively collect every node belonging to a subgraph, including nodes in nested subgraphs. */
340
+ function collectSubgraphMembers(sg: AsciiSubgraph): AsciiNode[] {
341
+ const members = [...sg.nodes]
342
+ for (const child of sg.children) {
343
+ members.push(...collectSubgraphMembers(child))
344
+ }
345
+ return members
346
+ }
347
+
348
+ /** Whether `to` is reachable from `from` by following outgoing edges (BFS). */
349
+ function isReachableViaEdges(
350
+ graph: AsciiGraph,
351
+ from: AsciiNode,
352
+ to: AsciiNode,
353
+ ): boolean {
354
+ if (from === to) return true
355
+ const visited = new Set<AsciiNode>([from])
356
+ const queue: AsciiNode[] = [from]
357
+ while (queue.length > 0) {
358
+ const current = queue.shift()!
359
+ for (const child of getChildren(graph, current)) {
360
+ if (child === to) return true
361
+ if (!visited.has(child)) {
362
+ visited.add(child)
363
+ queue.push(child)
364
+ }
365
+ }
366
+ }
367
+ return false
368
+ }
369
+
370
+ /**
371
+ * Get the effective direction for a node's layout.
372
+ * Returns the subgraph's direction override if the node is in a subgraph with one,
373
+ * otherwise returns the graph-level direction.
374
+ */
375
+ export function getEffectiveDirection(
376
+ graph: AsciiGraph,
377
+ node: AsciiNode,
378
+ ): 'LR' | 'TD' {
379
+ const sg = getNodeSubgraph(graph, node)
380
+ if (sg?.direction) {
381
+ return sg.direction
382
+ }
383
+ return graph.config.graphDirection
384
+ }
385
+
386
+ /**
387
+ * Check if a node has an incoming edge from outside its subgraph
388
+ * AND is the topmost such node in its subgraph.
389
+ * Used to add extra vertical padding for subgraph borders.
390
+ */
391
+ function hasIncomingEdgeFromOutsideSubgraph(
392
+ graph: AsciiGraph,
393
+ node: AsciiNode,
394
+ ): boolean {
395
+ const nodeSg = getNodeSubgraph(graph, node)
396
+ if (!nodeSg) return false
397
+
398
+ let hasExternalEdge = false
399
+ for (const edge of graph.edges) {
400
+ if (edge.to === node) {
401
+ const sourceSg = getNodeSubgraph(graph, edge.from)
402
+ if (sourceSg !== nodeSg) {
403
+ hasExternalEdge = true
404
+ break
405
+ }
406
+ }
407
+ }
408
+
409
+ if (!hasExternalEdge) return false
410
+
411
+ // Only return true for the topmost node with an external incoming edge
412
+ const nodeY = requireGridCoord(node).y
413
+ for (const otherNode of nodeSg.nodes) {
414
+ if (otherNode === node || !otherNode.gridCoord) continue
415
+ let otherHasExternal = false
416
+ for (const edge of graph.edges) {
417
+ if (edge.to === otherNode) {
418
+ const sourceSg = getNodeSubgraph(graph, edge.from)
419
+ if (sourceSg !== nodeSg) {
420
+ otherHasExternal = true
421
+ break
422
+ }
423
+ }
424
+ }
425
+ if (otherHasExternal && otherNode.gridCoord.y < nodeY) {
426
+ return false
427
+ }
428
+ }
429
+
430
+ return true
431
+ }
432
+
433
+ // ============================================================================
434
+ // Subgraph bounding boxes
435
+ // ============================================================================
436
+
437
+ function calculateSubgraphBoundingBox(
438
+ graph: AsciiGraph,
439
+ sg: AsciiSubgraph,
440
+ ): void {
441
+ if (sg.nodes.length === 0) return
442
+
443
+ let minX = 1_000_000
444
+ let minY = 1_000_000
445
+ let maxX = -1_000_000
446
+ let maxY = -1_000_000
447
+
448
+ // Include children's bounding boxes
449
+ for (const child of sg.children) {
450
+ calculateSubgraphBoundingBox(graph, child)
451
+ if (child.nodes.length > 0) {
452
+ minX = Math.min(minX, child.minX)
453
+ minY = Math.min(minY, child.minY)
454
+ maxX = Math.max(maxX, child.maxX)
455
+ maxY = Math.max(maxY, child.maxY)
456
+ }
457
+ }
458
+
459
+ // Include node positions
460
+ for (const node of sg.nodes) {
461
+ if (!node.drawingCoord || !node.drawing) continue
462
+ const nodeMinX = node.drawingCoord.x
463
+ const nodeMinY = node.drawingCoord.y
464
+ const nodeMaxX = nodeMinX + node.drawing.length - 1
465
+ const nodeMaxY = nodeMinY + node.drawing[0]!.length - 1
466
+ minX = Math.min(minX, nodeMinX)
467
+ minY = Math.min(minY, nodeMinY)
468
+ maxX = Math.max(maxX, nodeMaxX)
469
+ maxY = Math.max(maxY, nodeMaxY)
470
+ }
471
+
472
+ const subgraphPadding = 2
473
+ const subgraphLabelSpace = 2
474
+ sg.minX = minX - subgraphPadding
475
+ sg.minY = minY - subgraphPadding - subgraphLabelSpace
476
+ sg.maxX = maxX + subgraphPadding
477
+ sg.maxY = maxY + subgraphPadding
478
+
479
+ // Widen the box, if necessary, so the subgraph's own cluster label fits
480
+ // without truncation. `drawSubgraphLabel` (draw-subgraphs.ts) writes the
481
+ // label into the interior columns `1..width-1` (width = maxX - minX), so
482
+ // the box needs `width >= labelWidth + 1` to avoid clipping the label
483
+ // against its own right border. Widening symmetrically (splitting any
484
+ // extra columns across both sides) keeps the child nodes visually
485
+ // centered inside the enlarged box rather than skewing it to one side.
486
+ const labelWidth = Math.max(
487
+ 0,
488
+ ...splitLines(sg.name).map((line) => displayWidth(line)),
489
+ )
490
+ const currentWidth = sg.maxX - sg.minX
491
+ const requiredWidth = labelWidth + 1
492
+ if (requiredWidth > currentWidth) {
493
+ const extra = requiredWidth - currentWidth
494
+ const extraLeft = Math.floor(extra / 2)
495
+ const extraRight = extra - extraLeft
496
+ sg.minX -= extraLeft
497
+ sg.maxX += extraRight
498
+ }
499
+ }
500
+
501
+ /** Ensure non-overlapping root subgraphs have minimum spacing. */
502
+ function ensureSubgraphSpacing(graph: AsciiGraph): void {
503
+ const minSpacing = 1
504
+ const rootSubgraphs = graph.subgraphs.filter(
505
+ (sg) => sg.parent === null && sg.nodes.length > 0,
506
+ )
507
+
508
+ for (let i = 0; i < rootSubgraphs.length; i++) {
509
+ for (let j = i + 1; j < rootSubgraphs.length; j++) {
510
+ const sg1 = rootSubgraphs[i]!
511
+ const sg2 = rootSubgraphs[j]!
512
+
513
+ // Horizontal overlap → adjust vertical
514
+ if (sg1.minX < sg2.maxX && sg1.maxX > sg2.minX) {
515
+ if (sg1.maxY >= sg2.minY - minSpacing && sg1.minY < sg2.minY) {
516
+ sg2.minY = sg1.maxY + minSpacing + 1
517
+ } else if (sg2.maxY >= sg1.minY - minSpacing && sg2.minY < sg1.minY) {
518
+ sg1.minY = sg2.maxY + minSpacing + 1
519
+ }
520
+ }
521
+ // Vertical overlap → adjust horizontal
522
+ if (sg1.minY < sg2.maxY && sg1.maxY > sg2.minY) {
523
+ if (sg1.maxX >= sg2.minX - minSpacing && sg1.minX < sg2.minX) {
524
+ sg2.minX = sg1.maxX + minSpacing + 1
525
+ } else if (sg2.maxX >= sg1.minX - minSpacing && sg2.minX < sg1.minX) {
526
+ sg1.minX = sg2.maxX + minSpacing + 1
527
+ }
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ export function calculateSubgraphBoundingBoxes(graph: AsciiGraph): void {
534
+ for (const sg of graph.subgraphs) {
535
+ calculateSubgraphBoundingBox(graph, sg)
536
+ }
537
+ ensureSubgraphSpacing(graph)
538
+ }
539
+
540
+ /**
541
+ * Offset all drawing coordinates so subgraph borders don't go negative.
542
+ * If any subgraph has negative min coordinates, shift everything positive.
543
+ */
544
+ export function offsetDrawingForSubgraphs(graph: AsciiGraph): void {
545
+ if (graph.subgraphs.length === 0) return
546
+
547
+ let minX = 0
548
+ let minY = 0
549
+ for (const sg of graph.subgraphs) {
550
+ minX = Math.min(minX, sg.minX)
551
+ minY = Math.min(minY, sg.minY)
552
+ }
553
+
554
+ const offsetX = -minX
555
+ const offsetY = -minY
556
+ if (offsetX === 0 && offsetY === 0) return
557
+
558
+ graph.offsetX = offsetX
559
+ graph.offsetY = offsetY
560
+
561
+ for (const sg of graph.subgraphs) {
562
+ sg.minX += offsetX
563
+ sg.minY += offsetY
564
+ sg.maxX += offsetX
565
+ sg.maxY += offsetY
566
+ }
567
+
568
+ for (const node of graph.nodes) {
569
+ if (node.drawingCoord) {
570
+ node.drawingCoord.x += offsetX
571
+ node.drawingCoord.y += offsetY
572
+ }
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Group root nodes by which downstream target they feed into, preserving a
578
+ * stable order: groups appear in the order their shared target was first
579
+ * seen among `roots`, and a root with no children (or whose target no other
580
+ * root shares) keeps its original relative position.
581
+ *
582
+ * Fixes fan-in root placement: without this, `createMapping` places roots
583
+ * sequentially in whatever order they were discovered, so e.g. `A1, B1, A2,
584
+ * B2` (all roots, A1/A2 feeding A and B1/B2 feeding B) land interleaved on
585
+ * the grid instead of grouped as `A1, A2, B1, B2` — causing the two fan-in
586
+ * bundles' trunk edges to share a row and visually cross.
587
+ */
588
+ function groupRootsByDownstreamTarget(
589
+ graph: AsciiGraph,
590
+ roots: AsciiNode[],
591
+ ): AsciiNode[] {
592
+ const primaryTargetKey = (node: AsciiNode): string | null => {
593
+ const children = getChildren(graph, node)
594
+ return children.length > 0 ? children[0]!.name : null
595
+ }
596
+
597
+ // For each root, find the index (within `roots`) of the first root that
598
+ // shares its primary target — that's this root's sort anchor.
599
+ const firstIndexForTarget = new Map<string, number>()
600
+ const anchorIndex: number[] = roots.map((node, i) => {
601
+ const key = primaryTargetKey(node)
602
+ if (key === null) return i // no downstream target: anchor to self
603
+ const existing = firstIndexForTarget.get(key)
604
+ if (existing !== undefined) return existing
605
+ firstIndexForTarget.set(key, i)
606
+ return i
607
+ })
608
+
609
+ return roots
610
+ .map((node, i) => ({ node, i }))
611
+ .sort((a, b) => anchorIndex[a.i]! - anchorIndex[b.i]! || a.i - b.i)
612
+ .map(({ node }) => node)
613
+ }
614
+
615
+ /**
616
+ * A node whose every incoming path loops back through itself (e.g. `A -->
617
+ * B --> C --> A`, or a lone self-loop `A --> A`) is, correctly, never a
618
+ * "root" — every node in the cycle has a real incoming edge. But the grid
619
+ * layout still needs at least one seed node per such component to place
620
+ * anything at all; with zero roots feeding it, that component would never
621
+ * get a gridCoord and later crash (setColumnWidth calls `requireGridCoord`).
622
+ *
623
+ * The old order-dependent detection accidentally provided this seed — the
624
+ * first node the scan reached "looked like" a root simply because it
625
+ * hadn't been visited as a target *yet* — which is the exact bug fixed
626
+ * above. This restores just the useful part of that behavior for genuine
627
+ * cycles: for each weakly-connected component not reachable from any real
628
+ * root, seed it with its first-declared (graph.nodes order) node.
629
+ */
630
+ function addPseudoRootsForUnreachableCycles(
631
+ graph: AsciiGraph,
632
+ roots: AsciiNode[],
633
+ ): AsciiNode[] {
634
+ const reachable = new Set<string>()
635
+ const floodFrom = (start: AsciiNode): void => {
636
+ const queue: AsciiNode[] = [start]
637
+ while (queue.length > 0) {
638
+ const n = queue.shift()!
639
+ if (reachable.has(n.name)) continue
640
+ reachable.add(n.name)
641
+ for (const child of getChildren(graph, n)) queue.push(child)
642
+ }
643
+ }
644
+ for (const root of roots) floodFrom(root)
645
+
646
+ const result = [...roots]
647
+ for (const node of graph.nodes) {
648
+ if (reachable.has(node.name)) continue
649
+ result.push(node)
650
+ floodFrom(node)
651
+ }
652
+ return result
653
+ }
654
+
655
+ /**
656
+ * Find the node whose grid block originates at `coord`, if any.
657
+ *
658
+ * Only meaningful before edge routing starts: every reserved grid cell at
659
+ * that point belongs to a placed node's block (edges haven't claimed any
660
+ * cells yet), and every block's origin sits on the 4-unit lattice that
661
+ * `reserveSpotInGrid` allocates from — so matching by exact origin equality
662
+ * is safe; there's no partial-overlap case to worry about yet.
663
+ */
664
+ function findNodeAtGridOrigin(
665
+ graph: AsciiGraph,
666
+ coord: GridCoord,
667
+ ): AsciiNode | undefined {
668
+ return graph.nodes.find(
669
+ (n) =>
670
+ n.gridCoord !== null &&
671
+ n.gridCoord.x === coord.x &&
672
+ n.gridCoord.y === coord.y,
673
+ )
674
+ }
675
+
676
+ /**
677
+ * Find a free grid slot adjacent to `anchor` along `axis`, without walking
678
+ * through a node that belongs to a *different* top-level subgraph than
679
+ * `ownTopSg`.
680
+ *
681
+ * A deferred subgraph root (see the module doc above createMapping) anchors
682
+ * next to an already-placed sibling and slides along the shared axis until
683
+ * it finds free space. Sliding blindly — the naive approach, via
684
+ * `reserveSpotInGrid`'s generic collision handling — can walk straight
685
+ * through an unrelated sibling subgraph's node that already occupies the
686
+ * next slot over, landing the deferred node on the *far* side of that
687
+ * foreign node. That foreign node then sits between the anchor and the
688
+ * deferred node, so it falls inside this subgraph's bounding box and its own
689
+ * frame/title is dropped (#301).
690
+ *
691
+ * Tries `preferredSign` first (the direction the old blind slide always
692
+ * used), then the opposite sign. In each direction, stops — without
693
+ * accepting the enclosure — the moment it would have to step past a node
694
+ * belonging to a different top-level subgraph, and only continues past
695
+ * nodes belonging to the *same* one. Returns null if both directions are
696
+ * immediately foreign-blocked, so the caller can fall back to the old
697
+ * (occasionally imperfect but non-looping) blind-slide behavior.
698
+ */
699
+ function findSubgraphAdjacentSlot(
700
+ graph: AsciiGraph,
701
+ anchor: GridCoord,
702
+ ownTopSg: AsciiSubgraph,
703
+ axis: 'x' | 'y',
704
+ preferredSign: 1 | -1,
705
+ ): GridCoord | null {
706
+ const other: 'x' | 'y' = axis === 'x' ? 'y' : 'x'
707
+
708
+ const tryDirection = (sign: 1 | -1): GridCoord | null => {
709
+ let offset = 4
710
+ for (;;) {
711
+ const axisVal = anchor[axis] + sign * offset
712
+ if (axisVal < 0) return null
713
+ const candidate: GridCoord =
714
+ axis === 'x'
715
+ ? { x: axisVal, y: anchor[other] }
716
+ : { x: anchor[other], y: axisVal }
717
+ if (isBlockFree(graph.grid, candidate, NODE_BLOCK_SIZE)) return candidate
718
+ const occupant = findNodeAtGridOrigin(graph, candidate)
719
+ if (!occupant || getTopLevelSubgraph(graph, occupant) !== ownTopSg) {
720
+ return null // a foreign subgraph (or unexpected gap) blocks this side
721
+ }
722
+ offset += 4
723
+ }
724
+ }
725
+
726
+ return (
727
+ tryDirection(preferredSign) ?? tryDirection((preferredSign * -1) as 1 | -1)
728
+ )
729
+ }
730
+
731
+ /**
732
+ * Place any deferred nodes waiting on `rootNode`'s top-level subgraph
733
+ * immediately adjacent to it, before returning control to the root-
734
+ * placement loop — so an unrelated subgraph's root can never claim the slot
735
+ * a deferred sibling needs first (#301). Removes the subgraph's entry from
736
+ * `deferredByTopSg` once handled (a subgraph's deferred nodes attach to
737
+ * whichever of its roots is placed *first*, not every one), and records each
738
+ * placed node in `resolvedDeferred` so the later fallback pass (for deferred
739
+ * nodes whose anchor turns out to be a non-root, only available after the
740
+ * reachable-children traversal) skips them.
741
+ */
742
+ function placeDeferredSiblingsNextToRoot(
743
+ graph: AsciiGraph,
744
+ rootNode: AsciiNode,
745
+ deferredByTopSg: Map<AsciiSubgraph, AsciiNode[]>,
746
+ resolvedDeferred: Set<AsciiNode>,
747
+ ): void {
748
+ const topSg = getTopLevelSubgraph(graph, rootNode)
749
+ if (!topSg) return
750
+ const waiting = deferredByTopSg.get(topSg)
751
+ if (!waiting) return
752
+ deferredByTopSg.delete(topSg)
753
+
754
+ const anchor = requireGridCoord(rootNode)
755
+ const axis: 'x' | 'y' =
756
+ getEffectiveDirection(graph, rootNode) === 'LR' ? 'y' : 'x'
757
+
758
+ for (const deferred of waiting) {
759
+ const nodeDir = getEffectiveDirection(graph, deferred)
760
+ const slot = findSubgraphAdjacentSlot(graph, anchor, topSg, axis, 1)
761
+ reserveSpotInGrid(
762
+ graph,
763
+ graph.nodes[deferred.index]!,
764
+ slot ?? anchor,
765
+ nodeDir,
766
+ )
767
+ resolvedDeferred.add(deferred)
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Place all currently-reachable, still-unplaced children of already-placed
773
+ * nodes, level by level, mutating `highestPositionPerLevel` as it goes.
774
+ * Multi-pass: iterates until no more progress can be made in a full pass
775
+ * (handles non-topological node order, and simply leaves anything
776
+ * unreachable from an already-placed node untouched).
777
+ */
778
+ function placeReachableChildren(
779
+ graph: AsciiGraph,
780
+ highestPositionPerLevel: number[],
781
+ ): void {
782
+ let progressed = true
783
+ while (progressed) {
784
+ progressed = false
785
+
786
+ // Visit already-placed nodes in cross-axis order (left-to-right for TD,
787
+ // top-to-bottom for LR) rather than raw `graph.nodes` declaration
788
+ // order. `highestPositionPerLevel[level]` hands out each level's next
789
+ // free slot in visiting order, so a parent that's actually positioned
790
+ // further along the cross axis must also be visited later — otherwise
791
+ // its children claim an earlier (visually misaligned) slot than a
792
+ // parent positioned before it, decoupling a child's column/row from its
793
+ // own parent's. This matters once sibling placement order can diverge
794
+ // from `graph.nodes` order — e.g. `compareBySiblingSubgraphOrder`
795
+ // above, which places a later-declared sibling subgraph's root before
796
+ // an earlier-declared one's (see issue #444).
797
+ const crossAxisOf = (n: AsciiNode): number =>
798
+ graph.config.graphDirection === 'LR'
799
+ ? (n.gridCoord?.y ?? 0)
800
+ : (n.gridCoord?.x ?? 0)
801
+ const placedNodes = graph.nodes
802
+ .filter((n) => n.gridCoord !== null)
803
+ .sort((a, b) => crossAxisOf(a) - crossAxisOf(b))
804
+
805
+ for (const node of placedNodes) {
806
+ const gc = node.gridCoord
807
+ if (gc === null) continue // unreachable: `placedNodes` is pre-filtered
808
+
809
+ // Sort children so siblings that land in different subgraphs get
810
+ // mermaid.js's reversed-declaration-order sibling placement (see
811
+ // compareBySiblingSubgraphOrder) instead of raw edge-declaration
812
+ // order — the sort is stable, so pairs with no subgraph-order
813
+ // preference (the common case) keep their original relative order.
814
+ const children = [...getChildren(graph, node)].sort((x, y) =>
815
+ compareBySiblingSubgraphOrder(graph, x, y),
816
+ )
817
+ for (const child of children) {
818
+ if (child.gridCoord !== null) continue // already placed
819
+
820
+ // Determine direction for this edge (parent -> child)
821
+ // Use subgraph direction only if both are in the same subgraph with override
822
+ const parentSg = getNodeSubgraph(graph, node)
823
+ const childSg = getNodeSubgraph(graph, child)
824
+ const edgeDir =
825
+ parentSg && parentSg === childSg && parentSg.direction
826
+ ? parentSg.direction
827
+ : graph.config.graphDirection
828
+
829
+ const childLevel = edgeDir === 'LR' ? gc.x + 4 : gc.y + 4
830
+
831
+ // Determine position based on direction context
832
+ let highestPosition: number
833
+ if (edgeDir !== graph.config.graphDirection) {
834
+ // Cross-direction: use parent's perpendicular coordinate
835
+ // This keeps children aligned with parent when direction changes
836
+ highestPosition = edgeDir === 'LR' ? gc.y : gc.x
837
+ } else {
838
+ // Same direction: use level tracker
839
+ highestPosition = highestPositionPerLevel[childLevel] ?? 0
840
+ }
841
+
842
+ const requested: GridCoord =
843
+ edgeDir === 'LR'
844
+ ? { x: childLevel, y: highestPosition }
845
+ : { x: highestPosition, y: childLevel }
846
+ reserveSpotInGrid(graph, graph.nodes[child.index]!, requested, edgeDir)
847
+
848
+ // Only update level tracker for same-direction placements
849
+ if (edgeDir === graph.config.graphDirection) {
850
+ highestPositionPerLevel[childLevel] = highestPosition + 4
851
+ }
852
+ progressed = true
853
+ }
854
+ }
855
+ }
856
+ }
857
+
858
+ // ============================================================================
859
+ // Main layout orchestrator
860
+ // ============================================================================
861
+
862
+ /**
863
+ * createMapping performs the full grid layout:
864
+ * 1. Place root nodes on the grid
865
+ * 2. Place child nodes level by level
866
+ * 3. Compute column widths and row heights
867
+ * 4. Run A* pathfinding for all edges
868
+ * 5. Determine label placement
869
+ * 6. Convert grid coords → drawing coords
870
+ * 7. Generate node box drawings
871
+ * 8. Calculate subgraph bounding boxes
872
+ */
873
+ export function createMapping(graph: AsciiGraph): void {
874
+ const dir = graph.config.graphDirection
875
+ // A sparse array, not a fixed-size preallocation: level indices grow with
876
+ // chain depth (each level adds 4 to the coordinate), and a long enough
877
+ // chain would silently read past a fixed bound. Reads default missing
878
+ // levels to 0 via `?? 0` below instead.
879
+ const highestPositionPerLevel: number[] = []
880
+
881
+ // Identify root nodes — nodes that are never the target of any edge.
882
+ //
883
+ // This must be order-independent: a single forward pass over graph.nodes
884
+ // (in Map-insertion / first-mention order) incorrectly treats a node as a
885
+ // root whenever it hasn't been seen as an edge target *yet* at the point
886
+ // it's visited. That misclassifies nodes when a `child -> parent` edge
887
+ // appears in the source *after* a `parent -> grandchild` edge (e.g. `A -->
888
+ // C` is declared before `A1 --> A`), since `A` looks unvisited-as-target
889
+ // when the loop reaches it.
890
+ //
891
+ // Two-pass fix: first collect every node that appears as the target of a
892
+ // non-self-loop edge (a self-loop shouldn't disqualify a node from being a
893
+ // root — see edge-bundling.ts's identical self-loop skip), then anything
894
+ // never targeted is a genuine root, independent of source order.
895
+ const targetedNames = new Set<string>()
896
+ for (const edge of graph.edges) {
897
+ if (edge.from === edge.to) continue // self-loop: doesn't count as "targeted"
898
+ targetedNames.add(edge.to.name)
899
+ }
900
+ const targetBasedRoots: AsciiNode[] = graph.nodes.filter(
901
+ (node) => !targetedNames.has(node.name),
902
+ )
903
+
904
+ // A weakly-connected component that's entirely a cycle (e.g. `A --> B -->
905
+ // C --> A`) correctly has zero target-based roots — every node in it has
906
+ // a real incoming edge — but the grid layout still needs one seed node
907
+ // per component to place anything at all. See
908
+ // addPseudoRootsForUnreachableCycles for why and how.
909
+ const initialRoots = addPseudoRootsForUnreachableCycles(
910
+ graph,
911
+ targetBasedRoots,
912
+ )
913
+
914
+ // Filter out subgraph nodes that have incoming edges from external sources.
915
+ // This handles the case where subgraph is declared before external nodes
916
+ // (e.g., `subgraph s; A-->B; end; X-->A` - A shouldn't be a root, X should).
917
+ const rootNodes = initialRoots.filter((node) => {
918
+ const nodeSg = getNodeSubgraph(graph, node)
919
+ if (!nodeSg) return true // external nodes: keep as roots
920
+
921
+ // Check if this subgraph node has incoming edges from outside its subgraph
922
+ for (const edge of graph.edges) {
923
+ if (edge.to === node) {
924
+ const sourceSg = getNodeSubgraph(graph, edge.from)
925
+ if (sourceSg !== nodeSg) {
926
+ return false // has external incoming edge → not a root
927
+ }
928
+ }
929
+ }
930
+ return true
931
+ })
932
+
933
+ // Defer root nodes that belong to a subgraph which has OTHER members that
934
+ // are (a) not roots themselves and (b) not even reachable from this root
935
+ // via its own edges — i.e. members whose placement is driven by some
936
+ // completely unrelated part of the graph. Placing such a node at the
937
+ // generic root level — shared with roots of unrelated sibling subgraphs —
938
+ // can scatter its own subgraph's members across disjoint regions of the
939
+ // grid, making that subgraph's bounding box balloon out to enclose
940
+ // unrelated sibling content (#90). Instead, anchor these nodes next to
941
+ // their already-placed subgraph siblings once the normal placement pass
942
+ // below has run.
943
+ //
944
+ // A sibling that *is* reachable from this root (e.g. a subgraph root with
945
+ // its own intra-subgraph child) is left alone: the normal traversal below
946
+ // already positions it correctly relative to this root, so deferring would
947
+ // be both unnecessary and wrong.
948
+ const rootNodeSet = new Set(rootNodes)
949
+ const deferredRoots: AsciiNode[] = []
950
+ const placementRoots: AsciiNode[] = []
951
+ for (const node of rootNodes) {
952
+ const topSg = getTopLevelSubgraph(graph, node)
953
+ const hasUnrelatedNonRootSibling =
954
+ topSg !== null &&
955
+ collectSubgraphMembers(topSg).some(
956
+ (m) =>
957
+ m !== node &&
958
+ !rootNodeSet.has(m) &&
959
+ !isReachableViaEdges(graph, node, m),
960
+ )
961
+ if (hasUnrelatedNonRootSibling) {
962
+ deferredRoots.push(node)
963
+ } else {
964
+ placementRoots.push(node)
965
+ }
966
+ }
967
+
968
+ // Group the non-deferred root nodes by which downstream target they feed
969
+ // into, so a fan-in cluster (e.g. A1, A2 -> A) is placed contiguously
970
+ // instead of interleaving with roots that feed a *different* target (e.g.
971
+ // B1 -> B landing between A1 and A2). Without this, unrelated fan-in
972
+ // bundles can end up on the same grid row and their trunk edges visually
973
+ // cross.
974
+ //
975
+ // Stable: each root's sort key is the position of the *first* root that
976
+ // shares its primary (first) downstream target, so groups appear in the
977
+ // order their target was first seen, and a root with no children (or a
978
+ // target no other root shares) keeps its original relative position.
979
+ const groupedRootNodes = groupRootsByDownstreamTarget(graph, placementRoots)
980
+
981
+ // Deferred nodes grouped by their top-level subgraph, so the placement
982
+ // loops below can attach each subgraph's deferred members to whichever of
983
+ // its roots gets placed first — before any *other* subgraph's root gets a
984
+ // chance to claim the adjacent slot (#301). Entries are removed as they're
985
+ // resolved; `resolvedDeferred` then lets the later fallback pass skip
986
+ // anything already placed this way.
987
+ const deferredByTopSg = new Map<AsciiSubgraph, AsciiNode[]>()
988
+ for (const node of deferredRoots) {
989
+ const topSg = getTopLevelSubgraph(graph, node)!
990
+ const list = deferredByTopSg.get(topSg)
991
+ if (list) list.push(node)
992
+ else deferredByTopSg.set(topSg, [node])
993
+ }
994
+ const resolvedDeferred = new Set<AsciiNode>()
995
+
996
+ // In LR mode with both external and subgraph roots, separate them
997
+ // so subgraph roots are placed one level deeper
998
+ let hasExternalRoots = false
999
+ let hasSubgraphRootsWithEdges = false
1000
+ for (const node of groupedRootNodes) {
1001
+ if (isNodeInAnySubgraph(graph, node)) {
1002
+ if (getChildren(graph, node).length > 0) hasSubgraphRootsWithEdges = true
1003
+ } else {
1004
+ hasExternalRoots = true
1005
+ }
1006
+ }
1007
+ const shouldSeparate =
1008
+ dir === 'LR' && hasExternalRoots && hasSubgraphRootsWithEdges
1009
+
1010
+ let externalRootNodes: AsciiNode[]
1011
+ let subgraphRootNodes: AsciiNode[] = []
1012
+
1013
+ if (shouldSeparate) {
1014
+ externalRootNodes = groupedRootNodes.filter(
1015
+ (n) => !isNodeInAnySubgraph(graph, n),
1016
+ )
1017
+ subgraphRootNodes = groupedRootNodes.filter((n) =>
1018
+ isNodeInAnySubgraph(graph, n),
1019
+ )
1020
+ } else {
1021
+ externalRootNodes = groupedRootNodes
1022
+ }
1023
+
1024
+ // Place external root nodes
1025
+ for (const node of externalRootNodes) {
1026
+ const requested: GridCoord =
1027
+ dir === 'LR'
1028
+ ? { x: 0, y: highestPositionPerLevel[0] ?? 0 }
1029
+ : { x: highestPositionPerLevel[0] ?? 0, y: 0 }
1030
+ reserveSpotInGrid(graph, graph.nodes[node.index]!, requested)
1031
+ highestPositionPerLevel[0] = (highestPositionPerLevel[0] ?? 0) + 4
1032
+ placeDeferredSiblingsNextToRoot(
1033
+ graph,
1034
+ node,
1035
+ deferredByTopSg,
1036
+ resolvedDeferred,
1037
+ )
1038
+ }
1039
+
1040
+ // Place subgraph root nodes at level 4 (one level in from the edge)
1041
+ if (shouldSeparate && subgraphRootNodes.length > 0) {
1042
+ const subgraphLevel = 4
1043
+ for (const node of subgraphRootNodes) {
1044
+ const requested: GridCoord =
1045
+ dir === 'LR'
1046
+ ? { x: subgraphLevel, y: highestPositionPerLevel[subgraphLevel] ?? 0 }
1047
+ : { x: highestPositionPerLevel[subgraphLevel] ?? 0, y: subgraphLevel }
1048
+ reserveSpotInGrid(graph, graph.nodes[node.index]!, requested)
1049
+ highestPositionPerLevel[subgraphLevel] =
1050
+ (highestPositionPerLevel[subgraphLevel] ?? 0) + 4
1051
+ placeDeferredSiblingsNextToRoot(
1052
+ graph,
1053
+ node,
1054
+ deferredByTopSg,
1055
+ resolvedDeferred,
1056
+ )
1057
+ }
1058
+ }
1059
+
1060
+ // Place child nodes level by level (reachable from the roots placed so far).
1061
+ placeReachableChildren(graph, highestPositionPerLevel)
1062
+
1063
+ // Now place whatever deferred subgraph-orphan roots weren't already
1064
+ // resolved above (anchored to a root placed in this same subgraph) —
1065
+ // these are the ones whose anchor is itself a non-root, only placed by
1066
+ // the reachable-children traversal just above.
1067
+ for (const node of deferredRoots) {
1068
+ if (resolvedDeferred.has(node)) continue
1069
+ const topSg = getTopLevelSubgraph(graph, node)!
1070
+ const nodeDir = getEffectiveDirection(graph, node)
1071
+ // Type predicate narrows `gridCoord` to non-null on every element, so the
1072
+ // loop below can read `.gridCoord.x`/`.y` directly instead of trusting
1073
+ // that this filter and the access stay in sync via a bare `!`.
1074
+ const placedSiblings = collectSubgraphMembers(topSg).filter(
1075
+ (m): m is AsciiNode & { gridCoord: GridCoord } =>
1076
+ m !== node && m.gridCoord !== null,
1077
+ )
1078
+
1079
+ let requested: GridCoord
1080
+ if (placedSiblings.length > 0) {
1081
+ // Anchor to whichever placed sibling sits at the shallowest level
1082
+ // (topmost row for TD, leftmost column for LR).
1083
+ let anchor = placedSiblings[0]!
1084
+ for (const sibling of placedSiblings) {
1085
+ const isShallower =
1086
+ nodeDir === 'LR'
1087
+ ? sibling.gridCoord.x < anchor.gridCoord.x
1088
+ : sibling.gridCoord.y < anchor.gridCoord.y
1089
+ if (isShallower) anchor = sibling
1090
+ }
1091
+ // Slide along the shared axis to find free space next to the anchor,
1092
+ // staying clear of unrelated sibling subgraphs (#301) rather than
1093
+ // reserveSpotInGrid's subgraph-agnostic blind slide. Falls back to the
1094
+ // anchor's own coordinate (triggering the old blind slide inside
1095
+ // reserveSpotInGrid below) only when both directions are immediately
1096
+ // foreign-blocked.
1097
+ const axis: 'x' | 'y' = nodeDir === 'LR' ? 'y' : 'x'
1098
+ requested = findSubgraphAdjacentSlot(
1099
+ graph,
1100
+ anchor.gridCoord,
1101
+ topSg,
1102
+ axis,
1103
+ 1,
1104
+ ) ?? { x: anchor.gridCoord.x, y: anchor.gridCoord.y }
1105
+ } else {
1106
+ // Defensive fallback — shouldn't normally happen, since we only defer
1107
+ // a node when it has a sibling that's guaranteed to be placed by the
1108
+ // traversal above. Fall back to ordinary root-level placement.
1109
+ requested =
1110
+ nodeDir === 'LR'
1111
+ ? { x: 0, y: highestPositionPerLevel[0] ?? 0 }
1112
+ : { x: highestPositionPerLevel[0] ?? 0, y: 0 }
1113
+ /* v8 ignore next */
1114
+ highestPositionPerLevel[0] = (highestPositionPerLevel[0] ?? 0) + 4
1115
+ }
1116
+
1117
+ reserveSpotInGrid(graph, graph.nodes[node.index]!, requested, nodeDir)
1118
+ }
1119
+
1120
+ // A deferred root may itself have children (edges) that couldn't be placed
1121
+ // above since it wasn't on the grid yet — give the traversal another pass.
1122
+ placeReachableChildren(graph, highestPositionPerLevel)
1123
+
1124
+ // Compute column widths and row heights
1125
+ for (const node of graph.nodes) {
1126
+ setColumnWidth(graph, node)
1127
+ }
1128
+
1129
+ // Fresh render-wide A* iteration budget for this layout pass. Shared by
1130
+ // every getPath call below (both bundled-edge routing and per-edge
1131
+ // determinePath), so total pathfinding work for the whole render is
1132
+ // hard-bounded regardless of edge count — a per-call iteration cap alone
1133
+ // isn't enough for dense fan-in/out graphs with hundreds of edges, since
1134
+ // each call is independently allowed to spend up to its own cap. See
1135
+ // pathfinder.ts's PathBudget for details.
1136
+ graph.pathBudget = createPathBudget()
1137
+
1138
+ // Tag true parallel/multi-edges (same source AND target, e.g. two
1139
+ // separately-labeled A-->B edges) with a lane index before bundling
1140
+ // analysis runs, so determinePath below can route sibling edges past the
1141
+ // first through distinct offset lanes instead of all computing the
1142
+ // identical center path (see #329).
1143
+ assignParallelEdgeLanes(graph)
1144
+
1145
+ // Analyze edges for bundling (parallel links like A & B --> C)
1146
+ // This groups edges that share sources or targets for cleaner visualization
1147
+ graph.bundles = analyzeEdgeBundles(graph)
1148
+
1149
+ // Route bundled edges through junction points
1150
+ processBundles(graph)
1151
+
1152
+ // Route non-bundled edges via A* and determine label positions.
1153
+ //
1154
+ // `cellStyles` tracks which line style has claimed each cell an edge's
1155
+ // path has passed through so far. After routing a non-bundled edge,
1156
+ // `rerouteAroundStyleConflicts` checks whether its path crosses a cell
1157
+ // already claimed by a *different*-style edge — e.g. a solid edge and a
1158
+ // dotted back-edge with no shared source or target, independently
1159
+ // finding the same empty column — and if so, re-routes just that edge
1160
+ // around the conflicting cell(s). Same-style overlap is left completely
1161
+ // untouched: it's how sibling/bundled edges are meant to share a trunk
1162
+ // (see edge-cell-styles.ts's module doc).
1163
+ //
1164
+ // `nodeOnlyGrid` snapshots `graph.grid` right here — after all node
1165
+ // placement and bundle routing (which doesn't itself reserve into
1166
+ // `graph.grid`; see routeBundledEdges), before any per-edge temporary
1167
+ // reroute reservations start mutating the live `graph.grid` below. See
1168
+ // `rerouteAroundStyleConflicts`'s doc for why the conflict check needs
1169
+ // this frozen copy instead of the live grid.
1170
+ const cellStyles = createEdgeCellStyles()
1171
+ const cellOwners = createEdgeCellOwners()
1172
+ const nodeOnlyGrid = cloneGrid(graph.grid)
1173
+ for (const edge of graph.edges) {
1174
+ // Skip edges that were already routed as part of a bundle
1175
+ if (edge.bundle && edge.path.length > 0) {
1176
+ increaseGridSizeForPath(graph, edge.path)
1177
+ claimPathCells(nodeOnlyGrid, cellStyles, edge.path, edge.style)
1178
+ claimPathOwners(nodeOnlyGrid, cellOwners, edge.path, edge)
1179
+ determineLabelLine(graph, edge)
1180
+ continue
1181
+ }
1182
+
1183
+ determinePath(graph, edge)
1184
+ rerouteAroundStyleConflicts(
1185
+ graph,
1186
+ edge,
1187
+ cellStyles,
1188
+ cellOwners,
1189
+ nodeOnlyGrid,
1190
+ )
1191
+ increaseGridSizeForPath(graph, edge.path)
1192
+ claimPathCells(nodeOnlyGrid, cellStyles, edge.path, edge.style)
1193
+ claimPathOwners(nodeOnlyGrid, cellOwners, edge.path, edge)
1194
+ determineLabelLine(graph, edge)
1195
+ }
1196
+
1197
+ // Convert grid coords → drawing coords and generate box drawings
1198
+ for (const node of graph.nodes) {
1199
+ node.drawingCoord = gridToDrawingCoord(graph, requireGridCoord(node))
1200
+ node.drawing = drawBox(node, graph)
1201
+ }
1202
+
1203
+ // Compute subgraph bounding boxes and the resulting drawing offset
1204
+ // *before* sizing the canvas. `offsetDrawingForSubgraphs` can set
1205
+ // `graph.offsetX`/`offsetY` to a positive shift (needed whenever a
1206
+ // subgraph's own border padding would otherwise push its content
1207
+ // negative) — every `gridToDrawingCoord` call, including the ones
1208
+ // draw.ts makes later for edge lines, adds that offset to its result.
1209
+ // Sizing the canvas *before* this offset is known (the previous order)
1210
+ // left it too narrow/short by exactly `offsetX`/`offsetY`: node boxes
1211
+ // were retroactively shifted to the correct position (see below), but
1212
+ // nothing widened the canvas array to match, so any edge line whose
1213
+ // drawing coordinate landed in that unreserved margin was silently
1214
+ // dropped by `write()`'s out-of-bounds clip — invisible whenever
1215
+ // nothing happened to route that close to the diagram's far edge, but
1216
+ // a real, reproducible content loss once something did (see #1093,
1217
+ // the `Error --> Idle : retry` edge in the "State: Composite States"
1218
+ // sample, whose rerouted path was the first to reach it).
1219
+ calculateSubgraphBoundingBoxes(graph)
1220
+ offsetDrawingForSubgraphs(graph)
1221
+
1222
+ // Set canvas size, now covering the offset computed above.
1223
+ setCanvasSizeToGrid(
1224
+ graph.canvas,
1225
+ graph.columnWidth,
1226
+ graph.rowHeight,
1227
+ graph.offsetX,
1228
+ graph.offsetY,
1229
+ )
1230
+ setRoleCanvasSizeToGrid(
1231
+ graph.roleCanvas,
1232
+ graph.columnWidth,
1233
+ graph.rowHeight,
1234
+ graph.offsetX,
1235
+ graph.offsetY,
1236
+ )
1237
+ }
1238
+
1239
+ // ============================================================================
1240
+ // Graph traversal helpers
1241
+ // ============================================================================
1242
+
1243
+ /** Get all edges originating from a node. */
1244
+ function getEdgesFromNode(
1245
+ graph: AsciiGraph,
1246
+ node: AsciiNode,
1247
+ ): AsciiGraph['edges'] {
1248
+ return graph.edges.filter((e) => e.from.name === node.name)
1249
+ }
1250
+
1251
+ /**
1252
+ * Outermost-to-innermost chain of subgraphs directly containing `node`
1253
+ * (empty if the node isn't in any subgraph).
1254
+ */
1255
+ function subgraphChain(graph: AsciiGraph, node: AsciiNode): AsciiSubgraph[] {
1256
+ const innermost = getNodeSubgraph(graph, node)
1257
+ const chain: AsciiSubgraph[] = []
1258
+ let current: AsciiSubgraph | null = innermost
1259
+ while (current !== null) {
1260
+ chain.unshift(current)
1261
+ current = current.parent
1262
+ }
1263
+ return chain
1264
+ }
1265
+
1266
+ /**
1267
+ * Order two nodes the way real mermaid.js orders the sibling subgraphs they
1268
+ * (transitively) belong to, when they diverge into *different* subgraphs
1269
+ * under a shared subgraph ancestor (or both at the top level) — otherwise 0
1270
+ * (no preference; the caller's sort is stable, so original edge-declaration
1271
+ * order is preserved).
1272
+ *
1273
+ * Verified against mermaid@11.17.2's bundled flowDb.getData()
1274
+ * (node_modules/mermaid/dist/mermaid.min.js): it builds the layout node list
1275
+ * by iterating the parsed `subGraphs` array *backwards* to emit cluster
1276
+ * nodes, so sibling subgraphs end up in reversed declaration order in the
1277
+ * graph the layout engine actually sees — see the matching comment in
1278
+ * `../layout-engine/to-elk.ts`'s `mermaidToElk`, and issue #444. The ASCII
1279
+ * grid has no compound-node concept for the layout engine to order — node
1280
+ * position here is driven entirely by BFS descent from edges (see
1281
+ * `placeReachableChildren`) — so this comparator recovers the same visual
1282
+ * left-right order by reordering sibling children at the point they'd
1283
+ * otherwise be placed in raw edge-declaration order.
1284
+ */
1285
+ function compareBySiblingSubgraphOrder(
1286
+ graph: AsciiGraph,
1287
+ a: AsciiNode,
1288
+ b: AsciiNode,
1289
+ ): number {
1290
+ const chainA = subgraphChain(graph, a)
1291
+ const chainB = subgraphChain(graph, b)
1292
+ let i = 0
1293
+ while (i < chainA.length && i < chainB.length && chainA[i] === chainB[i]) {
1294
+ i++
1295
+ }
1296
+ const sgA = chainA[i]
1297
+ const sgB = chainB[i]
1298
+ if (!sgA || !sgB || sgA === sgB) return 0
1299
+ // Reversed declaration order: later-declared sibling sorts first. Sibling
1300
+ // relative declaration order is recovered from each one's position in the
1301
+ // flat `graph.subgraphs` list, which preserves source order (see
1302
+ // `convertSubgraph`'s pre-order-DFS push in converter.ts).
1303
+ return graph.subgraphs.indexOf(sgB) - graph.subgraphs.indexOf(sgA)
1304
+ }
1305
+
1306
+ /** Get all direct children of a node (targets of outgoing edges). */
1307
+ function getChildren(graph: AsciiGraph, node: AsciiNode): AsciiNode[] {
1308
+ return getEdgesFromNode(graph, node).map((e) => e.to)
1309
+ }