@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
@@ -0,0 +1,1070 @@
1
+ // ============================================================================
2
+ // ASCII renderer — direction system and edge path determination
3
+ //
4
+ // Ported from AlexanderGrooff/mermaid-ascii cmd/direction.go + cmd/mapping_edge.go.
5
+ // Handles direction constants, edge attachment point selection,
6
+ // and dual-path comparison for optimal edge routing.
7
+ // ============================================================================
8
+
9
+ import type {
10
+ GridCoord,
11
+ Direction,
12
+ AsciiEdge,
13
+ AsciiGraph,
14
+ AsciiNode,
15
+ } from './types.ts'
16
+ import {
17
+ Up,
18
+ Down,
19
+ Left,
20
+ Right,
21
+ UpperRight,
22
+ UpperLeft,
23
+ LowerRight,
24
+ LowerLeft,
25
+ Middle,
26
+ gridCoordDirection,
27
+ dirEquals,
28
+ requireCardinalDirection,
29
+ } from './types.ts'
30
+ import { routeEdge, mergePath } from './pathfinder.ts'
31
+ import { getNodeSubgraph, requireGridCoord } from './grid.ts'
32
+ import { displayWidth } from './display-width.ts'
33
+ import { isOccupied, pathCells } from './grid-occupancy.ts'
34
+
35
+ // Re-exported for existing consumers (draw-arrows.ts, draw-lines.ts,
36
+ // draw-bundles.ts, shapes/*.ts) that import dirEquals from this module —
37
+ // the implementation itself lives in types.ts now, alongside the other
38
+ // coordinate-equality helpers (gridCoordEquals, drawingCoordEquals).
39
+ export { dirEquals }
40
+
41
+ // ============================================================================
42
+ // Direction utilities
43
+ // ============================================================================
44
+
45
+ export function getOpposite(d: Direction): Direction {
46
+ if (d === Up) return Down
47
+ if (d === Down) return Up
48
+ if (d === Left) return Right
49
+ if (d === Right) return Left
50
+ if (d === UpperRight) return LowerLeft
51
+ if (d === UpperLeft) return LowerRight
52
+ if (d === LowerRight) return UpperLeft
53
+ if (d === LowerLeft) return UpperRight
54
+ return Middle
55
+ }
56
+
57
+ /**
58
+ * Determine 8-way direction from one coordinate to another.
59
+ * Uses the coordinate difference to pick one of 8 cardinal/ordinal directions.
60
+ */
61
+ export function determineDirection(
62
+ from: { x: number; y: number },
63
+ to: { x: number; y: number },
64
+ ): Direction {
65
+ if (from.x === to.x) {
66
+ return from.y < to.y ? Down : Up
67
+ } else if (from.y === to.y) {
68
+ return from.x < to.x ? Right : Left
69
+ } else if (from.x < to.x) {
70
+ return from.y < to.y ? LowerRight : UpperRight
71
+ } else {
72
+ return from.y < to.y ? LowerLeft : UpperLeft
73
+ }
74
+ }
75
+
76
+ // ============================================================================
77
+ // Start/end direction selection for edges
78
+ // ============================================================================
79
+
80
+ /** Self-reference routing (node points to itself). */
81
+ function selfReferenceDirection(
82
+ graphDirection: string,
83
+ ): [Direction, Direction, Direction, Direction] {
84
+ if (graphDirection === 'LR') return [Right, Down, Down, Right]
85
+ return [Down, Right, Right, Down]
86
+ }
87
+
88
+ /**
89
+ * Determine preferred and alternative start/end directions for an edge.
90
+ * Returns [preferredStart, preferredEnd, alternativeStart, alternativeEnd].
91
+ *
92
+ * The edge routing tries both pairs and picks the shorter path.
93
+ * Direction selection depends on relative node positions and graph direction (LR vs TD).
94
+ */
95
+ export function determineStartAndEndDir(
96
+ edge: AsciiEdge,
97
+ graphDirection: string,
98
+ ): [Direction, Direction, Direction, Direction] {
99
+ if (edge.from === edge.to) return selfReferenceDirection(graphDirection)
100
+
101
+ const d = determineDirection(
102
+ requireGridCoord(edge.from),
103
+ requireGridCoord(edge.to),
104
+ )
105
+
106
+ let preferredDir: Direction
107
+ let preferredOppositeDir: Direction
108
+ let alternativeDir: Direction
109
+ let alternativeOppositeDir: Direction
110
+
111
+ const isBackwards =
112
+ graphDirection === 'LR'
113
+ ? dirEquals(d, Left) || dirEquals(d, UpperLeft) || dirEquals(d, LowerLeft)
114
+ : dirEquals(d, Up) || dirEquals(d, UpperLeft) || dirEquals(d, UpperRight)
115
+
116
+ if (dirEquals(d, LowerRight)) {
117
+ if (graphDirection === 'LR') {
118
+ preferredDir = Down
119
+ preferredOppositeDir = Left
120
+ alternativeDir = Right
121
+ alternativeOppositeDir = Up
122
+ } else {
123
+ preferredDir = Right
124
+ preferredOppositeDir = Up
125
+ alternativeDir = Down
126
+ alternativeOppositeDir = Left
127
+ }
128
+ } else if (dirEquals(d, UpperRight)) {
129
+ if (graphDirection === 'LR') {
130
+ preferredDir = Up
131
+ preferredOppositeDir = Left
132
+ alternativeDir = Right
133
+ alternativeOppositeDir = Down
134
+ } else {
135
+ preferredDir = Right
136
+ preferredOppositeDir = Down
137
+ alternativeDir = Up
138
+ alternativeOppositeDir = Left
139
+ }
140
+ } else if (dirEquals(d, LowerLeft)) {
141
+ if (graphDirection === 'LR') {
142
+ preferredDir = Down
143
+ preferredOppositeDir = Down
144
+ alternativeDir = Left
145
+ alternativeOppositeDir = Up
146
+ } else {
147
+ preferredDir = Left
148
+ preferredOppositeDir = Up
149
+ alternativeDir = Down
150
+ alternativeOppositeDir = Right
151
+ }
152
+ } else if (dirEquals(d, UpperLeft)) {
153
+ if (graphDirection === 'LR') {
154
+ preferredDir = Down
155
+ preferredOppositeDir = Down
156
+ alternativeDir = Left
157
+ alternativeOppositeDir = Down
158
+ } else {
159
+ preferredDir = Right
160
+ preferredOppositeDir = Right
161
+ alternativeDir = Up
162
+ alternativeOppositeDir = Right
163
+ }
164
+ } else if (isBackwards) {
165
+ if (graphDirection === 'LR' && dirEquals(d, Left)) {
166
+ preferredDir = Down
167
+ preferredOppositeDir = Down
168
+ alternativeDir = Left
169
+ alternativeOppositeDir = Right
170
+ } else if (graphDirection === 'TD' && dirEquals(d, Up)) {
171
+ preferredDir = Right
172
+ preferredOppositeDir = Right
173
+ alternativeDir = Up
174
+ alternativeOppositeDir = Down
175
+ } else {
176
+ preferredDir = d
177
+ preferredOppositeDir = getOpposite(d)
178
+ alternativeDir = d
179
+ alternativeOppositeDir = getOpposite(d)
180
+ }
181
+ } else {
182
+ // Default: go in the natural direction
183
+ preferredDir = d
184
+ preferredOppositeDir = getOpposite(d)
185
+ alternativeDir = d
186
+ alternativeOppositeDir = getOpposite(d)
187
+ }
188
+
189
+ return [
190
+ preferredDir,
191
+ preferredOppositeDir,
192
+ alternativeDir,
193
+ alternativeOppositeDir,
194
+ ]
195
+ }
196
+
197
+ // ============================================================================
198
+ // Parallel edge (multi-edge) lane assignment
199
+ // ============================================================================
200
+
201
+ /**
202
+ * Grid rows (for a horizontal departure) or columns (for a vertical
203
+ * departure) between successive parallel-edge lanes. See
204
+ * `buildParallelLanePath` below.
205
+ */
206
+ const PARALLEL_LANE_STEP = 2
207
+
208
+ /**
209
+ * Bound on how far `buildParallelLanePath` will push a lane outward
210
+ * (1 grid unit per attempt) to escape a cell occupied by some *other*
211
+ * node's reserved block. Generous enough for any realistic diagram (a
212
+ * stack of many dozens of sibling rows/columns), while still bounding the
213
+ * search — see that function's doc for why an unbounded search isn't safe
214
+ * to assume will terminate quickly on a pathological graph.
215
+ */
216
+ const MAX_LANE_OFFSET_SEARCH = 200
217
+
218
+ /** Whether `cell` falls inside `node`'s own reserved 3x3 block. */
219
+ function isCellInNodeBlock(node: AsciiNode, cell: GridCoord): boolean {
220
+ const gc = node.gridCoord
221
+ if (!gc) return false
222
+ return (
223
+ cell.x >= gc.x && cell.x <= gc.x + 2 && cell.y >= gc.y && cell.y <= gc.y + 2
224
+ )
225
+ }
226
+
227
+ /**
228
+ * Every cell in `cells` strictly between its own first and last entry is
229
+ * free of any node's reserved block — except a block listed in `ownNodes`,
230
+ * which is allowed. The first/last cells are excluded outright, because
231
+ * callers use this on lines/paths whose two endpoints are expected to
232
+ * touch a node border (where an edge departs/arrives) or an already-
233
+ * validated waypoint; only cells genuinely *passed through* matter here.
234
+ *
235
+ * Two callers, two different `ownNodes`:
236
+ * - `determineLabelLine` calls this with no exemptions at all: label text
237
+ * drawn across *any* node's border — including the edge's own source or
238
+ * target — corrupts that node's box-drawing characters (see #329's
239
+ * "Second┬Arrow" regression), so nothing is exempt there.
240
+ * - `buildParallelLanePath` calls this with `[edge.from, edge.to]`
241
+ * exempted: an edge's line is *expected* to graze its own node's other
242
+ * border cells on the way to its actual attachment point — that's
243
+ * exactly how the ├/┤/┬/┴ box connector glyphs get chosen elsewhere in
244
+ * this renderer (see edge-cell-styles.ts's module doc for the same
245
+ * "an edge's own node border is a legitimate shared cell" reasoning).
246
+ * Only a *different*, unrelated node's block is a genuine collision
247
+ * there.
248
+ */
249
+ function interiorCellsClearOfNodes(
250
+ graph: AsciiGraph,
251
+ cells: readonly GridCoord[],
252
+ ownNodes: readonly AsciiNode[] = [],
253
+ ): boolean {
254
+ for (let i = 1; i < cells.length - 1; i++) {
255
+ const cell = cells[i]!
256
+ if (!isOccupied(graph.grid, cell)) continue
257
+ if (ownNodes.some((n) => isCellInNodeBlock(n, cell))) continue
258
+ return false
259
+ }
260
+ return true
261
+ }
262
+
263
+ /**
264
+ * Whether `edge`'s two nodes are laid out side by side on the same grid
265
+ * row — the layout in which every edge between them, in either direction,
266
+ * routes through one *horizontal* channel and so shares one label row.
267
+ *
268
+ * This is the discriminator for whether a reverse-direction sibling needs a
269
+ * lane of its own; see `parallelGroupKey` below for why.
270
+ *
271
+ * Deliberately symmetric in `from`/`to` (it only compares the two grid
272
+ * coordinates), so an edge and its reciprocal partner always agree on the
273
+ * answer and therefore on their group key. A node with no grid coordinate
274
+ * yet answers `false`, which just leaves that edge on the conservative
275
+ * ordered-pair grouping.
276
+ */
277
+ function sharesHorizontalChannel(edge: AsciiEdge): boolean {
278
+ const from = edge.from.gridCoord
279
+ const to = edge.to.gridCoord
280
+ if (!from || !to) return false
281
+ return from.y === to.y && from.x !== to.x
282
+ }
283
+
284
+ /**
285
+ * Grouping key for `assignParallelEdgeLanes` below: two edges get separate
286
+ * lanes exactly when this returns the same key for both.
287
+ *
288
+ * The base case is the *ordered* pair (`A -->|One| B` twice, #329) — two
289
+ * edges in the same direction always compute the identical center path,
290
+ * whatever the layout.
291
+ *
292
+ * A *reverse*-direction sibling (`B --> A` alongside `A --> B`, #629) is a
293
+ * different edge with its own natural route, so it collides in one specific
294
+ * layout: when the two nodes sit side by side on the same grid row, both
295
+ * edges route through the same horizontal channel, which puts both labels
296
+ * on the same row and lets one edge's line overwrite the other's arrowhead.
297
+ * That is the case this key folds together, by ordering the two node names.
298
+ *
299
+ * When the pair is stacked vertically instead, `drawTextOnLine`
300
+ * (draw-arrows.ts) already de-collides the two labels by pulling each into
301
+ * its own half of the shared vertical segment — a mechanism built for
302
+ * exactly this shape (#530) that only applies to a vertical segment.
303
+ * Grouping those two edges would route one of them through a lane and take
304
+ * that mechanism out of play, so they stay in separate ordered groups.
305
+ *
306
+ * `JSON.stringify` over the name pair rather than a delimiter-joined
307
+ * string: a node name may itself contain the delimiter, and `["A B", "C"]`
308
+ * must not collide with `["A", "B C"]`.
309
+ */
310
+ function parallelGroupKey(edge: AsciiEdge): string {
311
+ const from = edge.from.name
312
+ const to = edge.to.name
313
+ if (sharesHorizontalChannel(edge) && to < from) {
314
+ return JSON.stringify([to, from])
315
+ }
316
+ return JSON.stringify([from, to])
317
+ }
318
+
319
+ /**
320
+ * Group edges that connect the same pair of nodes — two edges in the same
321
+ * direction (`A -->|One| B` and `A -->|Two| B`), or, when the two nodes are
322
+ * laid out side by side, an edge and its reverse-direction partner
323
+ * (`A -->|req| B` and `B -->|res| A`) — and tag each with its 0-based
324
+ * position in the group plus the group's size, via `edge.parallelLane`.
325
+ * `parallelGroupKey` above defines exactly which pairs group together, and
326
+ * why a vertically-stacked reciprocal pair is deliberately left out.
327
+ *
328
+ * `determinePath` below reads this to route every edge past the first
329
+ * through a distinct offset lane instead of the shared center path they
330
+ * would otherwise all compute independently (see #329: identical paths
331
+ * meant identically-positioned labels drawn on top of each other,
332
+ * corrupting each other's text — and #629, the same defect between an edge
333
+ * and its reverse-direction partner).
334
+ *
335
+ * Self-loops are excluded (`edge.from === edge.to`): they're routed and
336
+ * drawn as a dedicated loop shape, not a lane-offset line between two
337
+ * distinct nodes, so they're out of scope here.
338
+ *
339
+ * Must run before `analyzeEdgeBundles` (edge-bundling.ts): that module's
340
+ * `canBundle` also refuses to fold a lane-assigned group into one shared
341
+ * fan-in/fan-out trunk (see its own doc comment), but the two checks are
342
+ * independent — this function is the one that actually assigns lanes for
343
+ * `determinePath` to use.
344
+ *
345
+ * Must also run after node placement: `sharesHorizontalChannel` reads both
346
+ * nodes' grid coordinates (grid.ts's call site satisfies this — it runs
347
+ * this immediately before bundling analysis, well after layout).
348
+ */
349
+ export function assignParallelEdgeLanes(graph: AsciiGraph): void {
350
+ const groups = new Map<string, AsciiEdge[]>()
351
+ for (const edge of graph.edges) {
352
+ if (edge.from === edge.to) continue // self-loop: not in scope here
353
+ const key = parallelGroupKey(edge)
354
+ const existing = groups.get(key)
355
+ if (existing) existing.push(edge)
356
+ else groups.set(key, [edge])
357
+ }
358
+
359
+ for (const group of groups.values()) {
360
+ if (group.length < 2) continue
361
+ const usedOffsets = new Set<number>()
362
+ for (let i = 0; i < group.length; i++) {
363
+ group[i]!.parallelLane = { index: i, total: group.length, usedOffsets }
364
+ }
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Build an offset-lane path for a non-first edge in a parallel-edge group:
370
+ * leave the source node at the same attachment point every sibling in the
371
+ * group shares (consistent with how two edges are already allowed to share
372
+ * a node's own border cell — see edge-cell-styles.ts's module doc), travel
373
+ * an offset lane, then return to the target's attachment point. Two
374
+ * candidate shapes are tried per offset, in order:
375
+ *
376
+ * - **"wide"**: descend/cross straight down `fromAttach`'s own column/row,
377
+ * travel the *full* node-to-node span at the offset lane, then rise back
378
+ * up `toAttach`'s own column/row. This is what `determineLabelLine`
379
+ * wants — a segment already wide enough to host the label outright —
380
+ * and is correct whenever nothing else occupies that column/row at the
381
+ * chosen offset.
382
+ * - **"gutter"**: like "wide", but the two short jogs near each endpoint
383
+ * step one extra grid unit into the gutter column/row immediately
384
+ * before/after that node — reserved by every node's own
385
+ * `setColumnWidth` padding (`columnWidth.set(gc.x - 1, ...)` in
386
+ * grid.ts) and never claimed by `placeBlock` for *any* node, at *any*
387
+ * row/column, by construction of the grid-level spacing scheme (`x`/`y`
388
+ * levels are always `NODE_BLOCK_SIZE` + 1 apart). "wide" fails exactly
389
+ * when a second, independent `X --> Y` chain placed directly below
390
+ * `A`/`B` (a completely ordinary "second flow stacked under the first"
391
+ * layout) shares `A`'s/`B`'s exact border column, so "wide"'s straight
392
+ * descent runs right through `X`'s/`Y`'s own reserved block — "gutter"
393
+ * detours around exactly that, at the cost of a narrower (often single-
394
+ * column) middle segment; still a legitimate label location, just via
395
+ * determineLabelLine's node-avoidance fallback tier rather than its
396
+ * width-based one. (Routing each jog through `routeEdge`/A* was tried
397
+ * before landing on this: A* has no notion of "this occupied cell is my
398
+ * *own* node's, so it's fine to graze", so it detoured *away* from the
399
+ * source's/target's own border on every lane, producing a self-crossing
400
+ * zigzag instead of a clean jog.)
401
+ *
402
+ * Each candidate is validated via `interiorCellsClearOfNodes` (the edge's
403
+ * own two nodes exempted — grazing them is expected, exactly like the
404
+ * `edge-cell-styles.ts` "own node border is a legitimate shared cell"
405
+ * reasoning) before being accepted. If neither shape is clear at the
406
+ * current offset, it's pushed out one more grid unit and both are retried
407
+ * — bounded by `MAX_LANE_OFFSET_SEARCH` — mirroring `findNonNodeColumn`'s
408
+ * "search outward for a non-node column" pattern above, generalized to a
409
+ * whole path.
410
+ *
411
+ * The offset always grows in one direction (never negative) so it can
412
+ * never land on a grid row/column that already exists above/left of the
413
+ * node block — `gridToDrawingCoord`'s row/column summation only handles
414
+ * coordinates from 0 upward, and `increaseGridSizeForPath` (grid.ts) is
415
+ * what actually grows the canvas for a lane's newly-introduced row/column.
416
+ *
417
+ * `preferredDir`/`preferredOppositeDir` are whatever `determineStartAndEndDir`
418
+ * computed for this edge (identical for every edge in the group, since they
419
+ * share the same from/to nodes) — always one of the four cardinal
420
+ * directions. A horizontal departure (Left/Right) offsets by row; a
421
+ * vertical departure (Up/Down) offsets by column.
422
+ *
423
+ * If every offset within the search bound is still blocked for both shapes
424
+ * (a genuinely pathological, densely-stacked graph), the last attempted
425
+ * path is returned anyway rather than failing the whole render — the same
426
+ * graceful-degradation-over-a-hard-failure choice `findNonNodeColumn` and
427
+ * `determinePath`'s Case-4 direct fallback already make elsewhere in this
428
+ * file.
429
+ */
430
+ /** A lane path plus the segment `determinePath` should use directly as
431
+ * this edge's label line — see `buildParallelLanePath`'s doc for why this
432
+ * is computed explicitly here rather than left to `determineLabelLine`'s
433
+ * general (width-based) heuristic. */
434
+ interface ParallelLaneRoute {
435
+ path: GridCoord[]
436
+ labelSegment: [GridCoord, GridCoord]
437
+ }
438
+
439
+ function buildParallelLanePath(
440
+ graph: AsciiGraph,
441
+ edge: AsciiEdge,
442
+ preferredDir: Direction,
443
+ preferredOppositeDir: Direction,
444
+ laneIndex: number,
445
+ ): ParallelLaneRoute {
446
+ const fromAttach = gridCoordDirection(
447
+ requireGridCoord(edge.from),
448
+ preferredDir,
449
+ )
450
+ const toAttach = gridCoordDirection(
451
+ requireGridCoord(edge.to),
452
+ preferredOppositeDir,
453
+ )
454
+ const horizontalDeparture =
455
+ dirEquals(preferredDir, Left) || dirEquals(preferredDir, Right)
456
+ const ownNodes = [edge.from, edge.to]
457
+ // Shared, by reference, across every edge in this parallel group (see
458
+ // types.ts's parallelLane doc) — records every offset a sibling lane has
459
+ // already committed to, so two lanes that each have to detour around the
460
+ // same obstacle can't converge on the identical offset (and therefore
461
+ // the identical path — the exact bug this whole mechanism exists to
462
+ // prevent, just between two lanes instead of the original center path).
463
+ const usedOffsets = edge.parallelLane!.usedOffsets
464
+
465
+ // One grid unit further from each node, in the direction the edge
466
+ // already departs/arrives — lands in the permanently node-free gutter
467
+ // (see doc above) rather than the node's own border column/row.
468
+ const fromStep = dirEquals(preferredDir, Right)
469
+ ? 1
470
+ : dirEquals(preferredDir, Left)
471
+ ? -1
472
+ : dirEquals(preferredDir, Down)
473
+ ? 1
474
+ : -1
475
+ const toStep = dirEquals(preferredOppositeDir, Right)
476
+ ? 1
477
+ : dirEquals(preferredOppositeDir, Left)
478
+ ? -1
479
+ : dirEquals(preferredOppositeDir, Down)
480
+ ? 1
481
+ : -1
482
+ const fromGutter = horizontalDeparture
483
+ ? { x: fromAttach.x + fromStep, y: fromAttach.y }
484
+ : { x: fromAttach.x, y: fromAttach.y + fromStep }
485
+ const toGutter = horizontalDeparture
486
+ ? { x: toAttach.x + toStep, y: toAttach.y }
487
+ : { x: toAttach.x, y: toAttach.y + toStep }
488
+
489
+ // Always-valid fallback (ignores occupancy entirely) in case every
490
+ // offset attempt below fails outright — mirrors determinePath's own
491
+ // Case-4 direct fallback: an edge must always end up with *some* path so
492
+ // its arrowhead can still be drawn. Its label segment is the same
493
+ // degenerate zero-length line determineLabelLine itself falls back to
494
+ // for a path with no real segments — see this function's caller.
495
+ let path: GridCoord[] = [fromAttach, toAttach]
496
+ let labelSegment: [GridCoord, GridCoord] = [fromAttach, toAttach]
497
+
498
+ const startingOffset = PARALLEL_LANE_STEP * laneIndex
499
+ for (let step = 0; step < MAX_LANE_OFFSET_SEARCH; step++) {
500
+ const offset = startingOffset + step
501
+ if (usedOffsets.has(offset)) continue
502
+ const laneMain = fromAttach.y + offset
503
+ const laneCross = fromAttach.x + offset
504
+
505
+ // Candidate A ("wide"): travel the offset lane across the *full*
506
+ // node-to-node span (fromAttach.x..toAttach.x, or the vertical
507
+ // equivalent). Its middle segment is always genuinely wide (it spans
508
+ // two distinct nodes' attachment columns/rows), so it's used directly
509
+ // as the label segment — no need to search for it afterward. Tried
510
+ // first because it's clearly the better-looking result whenever safe.
511
+ const wideLabelSegment: [GridCoord, GridCoord] = horizontalDeparture
512
+ ? [
513
+ { x: fromAttach.x, y: laneMain },
514
+ { x: toAttach.x, y: laneMain },
515
+ ]
516
+ : [
517
+ { x: laneCross, y: fromAttach.y },
518
+ { x: laneCross, y: toAttach.y },
519
+ ]
520
+ const wideCandidate = mergePath([fromAttach, ...wideLabelSegment, toAttach])
521
+ if (interiorCellsClearOfNodes(graph, pathCells(wideCandidate), ownNodes)) {
522
+ usedOffsets.add(offset)
523
+ return { path: wideCandidate, labelSegment: wideLabelSegment }
524
+ }
525
+ path = wideCandidate
526
+ labelSegment = wideLabelSegment
527
+
528
+ // Candidate B ("gutter"): candidate A travels along fromAttach's/
529
+ // toAttach's own border column/row for the vertical/horizontal jog,
530
+ // which can run straight through an unrelated node sharing that same
531
+ // column/row (see this function's doc) — candidate A's occupancy
532
+ // check just caught exactly that. Detour those two short jogs through
533
+ // the permanently node-free gutter instead.
534
+ //
535
+ // `fromGutter`/`toGutter` coincide exactly whenever the two nodes sit
536
+ // on directly adjacent grid levels (the ordinary case for "two edges
537
+ // between the same pair") — there being only one gutter column/row
538
+ // between them. When that happens the "travel" segment collapses to a
539
+ // single point, which every sibling lane's gutter candidate would
540
+ // share identically — reintroducing #329's own bug, just between two
541
+ // *lanes* instead of the original center path (this was caught while
542
+ // building this fix: see ascii-parallel-edges-329.test.ts's "no lane
543
+ // path passes through a cell owned by the unrelated X/Y chain" test).
544
+ // Use the vertical/horizontal "spike" descent instead in that case — a
545
+ // zero-length point *at* this lane's own offset depth, not a segment
546
+ // spanning from the attach row/column down to it: drawTextOnLine
547
+ // centers text at a line's *midpoint*, so a segment running from (say)
548
+ // row 1 down to row 7 centers its label at row 4, not row 7 — two
549
+ // lanes at different depths (rows 5 and 7, say) can still produce the
550
+ // *same* midpoint as some other lane's own row, corrupting labels all
551
+ // over again (also caught by the same test named above). A point's
552
+ // min/max/midpoint are all itself, so the label always lands exactly
553
+ // at this lane's own unique depth — narrow (its column/row is widened
554
+ // below like any single-segment label host), but unique per lane.
555
+ const gutterTravelHasWidth = horizontalDeparture
556
+ ? fromGutter.x !== toGutter.x
557
+ : fromGutter.y !== toGutter.y
558
+ const gutterLabelSegment: [GridCoord, GridCoord] = horizontalDeparture
559
+ ? gutterTravelHasWidth
560
+ ? [
561
+ { x: fromGutter.x, y: laneMain },
562
+ { x: toGutter.x, y: laneMain },
563
+ ]
564
+ : [
565
+ { x: fromGutter.x, y: laneMain },
566
+ { x: fromGutter.x, y: laneMain },
567
+ ]
568
+ : gutterTravelHasWidth
569
+ ? [
570
+ { x: laneCross, y: fromGutter.y },
571
+ { x: laneCross, y: toGutter.y },
572
+ ]
573
+ : [
574
+ { x: laneCross, y: fromGutter.y },
575
+ { x: laneCross, y: fromGutter.y },
576
+ ]
577
+ const gutterCandidate = horizontalDeparture
578
+ ? mergePath([
579
+ fromAttach,
580
+ fromGutter,
581
+ { x: fromGutter.x, y: laneMain },
582
+ { x: toGutter.x, y: laneMain },
583
+ toGutter,
584
+ toAttach,
585
+ ])
586
+ : mergePath([
587
+ fromAttach,
588
+ fromGutter,
589
+ { x: laneCross, y: fromGutter.y },
590
+ { x: laneCross, y: toGutter.y },
591
+ toGutter,
592
+ toAttach,
593
+ ])
594
+ if (
595
+ interiorCellsClearOfNodes(graph, pathCells(gutterCandidate), ownNodes)
596
+ ) {
597
+ usedOffsets.add(offset)
598
+ return { path: gutterCandidate, labelSegment: gutterLabelSegment }
599
+ }
600
+ // Defensive — shouldn't normally happen. The gutter candidate's
601
+ // vertical/horizontal travel runs entirely through the permanently
602
+ // node-free gutter column/row (see this function's doc: every node's
603
+ // own column-width padding reserves it, and placeBlock never claims
604
+ // it for any node), so gutterCandidate failing this check would mean
605
+ // that structural guarantee didn't hold for this graph's layout.
606
+ // Recorded anyway so the loop's very last iteration still has a
607
+ // best-effort path/labelSegment to fall back to.
608
+ /* v8 ignore next */
609
+ path = gutterCandidate
610
+ /* v8 ignore next */
611
+ labelSegment = gutterLabelSegment
612
+ }
613
+ // Defensive — shouldn't normally happen, for the same reason as above:
614
+ // reaching here means every offset up to MAX_LANE_OFFSET_SEARCH failed
615
+ // both candidates, which requires the gutter's structural guarantee to
616
+ // not hold. Mirrors determinePath's own Case-4 direct fallback and
617
+ // findNonNodeColumn's "every column occupied" fallback elsewhere in this
618
+ // file: graceful degradation over a hard failure, not a case expected to
619
+ // be exercised by a realistic graph.
620
+ /* v8 ignore next */
621
+ return { path, labelSegment }
622
+ }
623
+
624
+ // ============================================================================
625
+ // Edge path determination
626
+ // ============================================================================
627
+
628
+ /**
629
+ * Determine the path for an edge by trying two candidate routes (preferred + alternative)
630
+ * and picking the shorter one. Sets edge.path, edge.startDir, edge.endDir.
631
+ *
632
+ * When both A* paths fail (common for edges crossing subgraph boundaries), falls back
633
+ * to a direct path using the start/end points. This ensures edges always have a path
634
+ * for arrowhead rendering.
635
+ *
636
+ * Uses the effective direction for edge routing, respecting subgraph direction overrides
637
+ * when both source and target are in the same subgraph.
638
+ */
639
+ export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void {
640
+ // Determine effective direction for this edge
641
+ // If both nodes are in the same subgraph with a direction override, use it
642
+ // Otherwise, use the graph's direction (not source's effective direction)
643
+ const sourceSg = getNodeSubgraph(graph, edge.from)
644
+ const targetSg = getNodeSubgraph(graph, edge.to)
645
+ const effectiveDir =
646
+ sourceSg && sourceSg === targetSg && sourceSg.direction
647
+ ? sourceSg.direction
648
+ : graph.config.graphDirection
649
+
650
+ const [
651
+ preferredDir,
652
+ preferredOppositeDir,
653
+ alternativeDir,
654
+ alternativeOppositeDir,
655
+ ] = determineStartAndEndDir(edge, effectiveDir)
656
+
657
+ // Edges after the first in a true-parallel (same source AND target) group
658
+ // skip the normal preferred/alternative search entirely: that search
659
+ // would just find the identical center path every sibling edge shares,
660
+ // which is the root cause of #329. Route through an offset lane instead
661
+ // so this edge's path — and its label — never overlaps a sibling's.
662
+ //
663
+ // The label line is set directly here (via applyLabelLine), not left for
664
+ // determineLabelLine's later call to work out: buildParallelLanePath
665
+ // already knows exactly which segment of the lane it just built is the
666
+ // correct, sibling-distinct place for the label (see its own doc for why
667
+ // determineLabelLine's general width-based heuristic can't reliably
668
+ // re-derive that from the finished path alone). determineLabelLine
669
+ // itself skips lane edges for exactly this reason — see its own guard.
670
+ if (edge.parallelLane && edge.parallelLane.index > 0) {
671
+ // A reverse-direction sibling (`B --> A` alongside `A --> B`, #629) runs
672
+ // "backwards" relative to the graph direction, so determineStartAndEndDir
673
+ // hands back a degenerate *same-side* pair — Down/Down in LR, Right/Right
674
+ // in TD — describing a U-shaped detour that leaves and re-enters on the
675
+ // same face. buildParallelLanePath's offset math can't express that
676
+ // shape: it picks its offset axis from whether the *departure* is
677
+ // horizontal, which for a Down/Down U-shape is exactly inverted (it
678
+ // offsets by column when the lane actually travels horizontally and needs
679
+ // a row offset), landing the lane on the nodes' own border rows. Use the
680
+ // alternative (straight-through) pair for such an edge instead, making a
681
+ // reverse-direction lane the mirror image of a forward one — the shape
682
+ // #329's fix already produces for a same-direction sibling. A forward
683
+ // edge's preferred and alternative pairs are identical (see
684
+ // determineStartAndEndDir's default branch), so this never changes how an
685
+ // existing same-direction group routes.
686
+ const degenerateFace = dirEquals(preferredDir, preferredOppositeDir)
687
+ const laneDir = degenerateFace ? alternativeDir : preferredDir
688
+ const laneOppositeDir = degenerateFace
689
+ ? alternativeOppositeDir
690
+ : preferredOppositeDir
691
+ edge.startDir = laneDir
692
+ edge.endDir = laneOppositeDir
693
+ const route = buildParallelLanePath(
694
+ graph,
695
+ edge,
696
+ laneDir,
697
+ laneOppositeDir,
698
+ edge.parallelLane.index,
699
+ )
700
+ edge.path = route.path
701
+ if (edge.text.length > 0) {
702
+ applyLabelLine(graph, edge, route.labelSegment, displayWidth(edge.text))
703
+ }
704
+ return
705
+ }
706
+
707
+ // Try preferred path — routeEdge tries an unobstructed direct L-shape
708
+ // before falling back to A* (see routeEdge / tryDirectPath in
709
+ // pathfinder.ts). determineStartAndEndDir only ever produces one of the
710
+ // four pure cardinal directions (see its implementation above), but that
711
+ // invariant lives in this function's control flow, not in Direction's
712
+ // type, so it's narrowed explicitly at the routeEdge boundary rather than
713
+ // trusted silently across the module.
714
+ const prefFrom = gridCoordDirection(requireGridCoord(edge.from), preferredDir)
715
+ const prefTo = gridCoordDirection(
716
+ requireGridCoord(edge.to),
717
+ preferredOppositeDir,
718
+ )
719
+ const preferredPath = routeEdge(
720
+ graph,
721
+ prefFrom,
722
+ prefTo,
723
+ requireCardinalDirection(preferredDir),
724
+ )
725
+
726
+ // Try alternative path
727
+ const altFrom = gridCoordDirection(
728
+ requireGridCoord(edge.from),
729
+ alternativeDir,
730
+ )
731
+ const altTo = gridCoordDirection(
732
+ requireGridCoord(edge.to),
733
+ alternativeOppositeDir,
734
+ )
735
+ const alternativePath = routeEdge(
736
+ graph,
737
+ altFrom,
738
+ altTo,
739
+ requireCardinalDirection(alternativeDir),
740
+ )
741
+
742
+ // Case 1: Both paths found — pick the shorter one (routeEdge already merged each)
743
+ if (preferredPath !== null && alternativePath !== null) {
744
+ if (preferredPath.length <= alternativePath.length) {
745
+ edge.startDir = preferredDir
746
+ edge.endDir = preferredOppositeDir
747
+ edge.path = preferredPath
748
+ } else {
749
+ edge.startDir = alternativeDir
750
+ edge.endDir = alternativeOppositeDir
751
+ edge.path = alternativePath
752
+ }
753
+ return
754
+ }
755
+
756
+ // Case 2: Only preferred path found
757
+ if (preferredPath !== null) {
758
+ edge.startDir = preferredDir
759
+ edge.endDir = preferredOppositeDir
760
+ edge.path = preferredPath
761
+ return
762
+ }
763
+
764
+ // Case 3: Only alternative path found
765
+ if (alternativePath !== null) {
766
+ edge.startDir = alternativeDir
767
+ edge.endDir = alternativeOppositeDir
768
+ edge.path = alternativePath
769
+ return
770
+ }
771
+
772
+ // Case 4: Both paths failed — create a direct fallback path
773
+ // This happens for edges crossing subgraph boundaries where A* can't find
774
+ // a clear route. We create a direct path from source to target exit points
775
+ // so arrowheads can still be rendered correctly.
776
+ //
777
+ // `[prefFrom, prefTo]` alone is a single non-axis-aligned (diagonal) grid
778
+ // segment whenever the two points differ on both axes. draw-lines.ts's
779
+ // drawLine never draws that as a straight diagonal — it draws it as an L,
780
+ // horizontal-first (see expandDiagonalSegments' own doc, and #418/#1083,
781
+ // which already had to special-case this same diagonal-vs-drawn-L gap for
782
+ // label placement and arrowhead direction respectively). Everything else
783
+ // that reasons about `edge.path` — in particular edge-cell-styles.ts's
784
+ // cross-style overlap detection, which walks `edge.path` via `pathCells`
785
+ // assuming it already matches the drawn geometry — implicitly assumes a
786
+ // path is axis-aligned. Left as a raw diagonal pair, `pathCells` walks a
787
+ // Bresenham diagonal that visits a completely different set of cells than
788
+ // the L drawLine actually draws, so two Case-4 edges (or a Case-4 edge and
789
+ // an ordinary routed one) whose *drawn* horizontal legs genuinely overlap
790
+ // are never flagged as a conflict, and the later-drawn edge's style
791
+ // silently overwrites the earlier one's on that shared row (#1067, "All
792
+ // Edge Styles": the dotted edge's horizontal leg rendered as the thick
793
+ // edge's heavy ━ instead of dashed). Expanding here — once, at the
794
+ // source — keeps every downstream consumer (drawing, label placement,
795
+ // conflict detection, corner glyphs) working from the same axis-aligned
796
+ // path instead of each needing its own diagonal-awareness.
797
+ edge.startDir = preferredDir
798
+ edge.endDir = preferredOppositeDir
799
+ edge.path = expandDiagonalSegments([prefFrom, prefTo])
800
+ }
801
+
802
+ /** Check whether grid column `x` falls inside any node's reserved 3-column block. */
803
+ function isNodeOccupiedColumn(graph: AsciiGraph, x: number): boolean {
804
+ for (const node of graph.nodes) {
805
+ const gc = node.gridCoord
806
+ if (gc && x >= gc.x && x <= gc.x + 2) return true
807
+ }
808
+ return false
809
+ }
810
+
811
+ /**
812
+ * Find the column closest to `ideal` (within [minX, maxX]) that isn't part
813
+ * of any node's reserved block, searching outward in both directions. Falls
814
+ * back to `ideal` itself if every column in range is node-occupied (e.g. a
815
+ * very short segment squeezed between two nodes) — there's no better option.
816
+ */
817
+ function findNonNodeColumn(
818
+ graph: AsciiGraph,
819
+ minX: number,
820
+ maxX: number,
821
+ ideal: number,
822
+ ): number {
823
+ if (!isNodeOccupiedColumn(graph, ideal)) return ideal
824
+ for (let d = 1; d <= maxX - minX; d++) {
825
+ const right = ideal + d
826
+ if (right <= maxX && !isNodeOccupiedColumn(graph, right)) return right
827
+ const left = ideal - d
828
+ if (left >= minX && !isNodeOccupiedColumn(graph, left)) return left
829
+ }
830
+ return ideal
831
+ }
832
+
833
+ /**
834
+ * Find the best line segment in an edge's path to place a label on.
835
+ * Prefers vertical segments for TD/BT graphs and horizontal for LR/RL to avoid
836
+ * label collisions when multiple edges share initial segments.
837
+ * Falls back to the widest segment if none are suitable.
838
+ * Also increases the column width at the label position to fit the text.
839
+ *
840
+ * No-ops for an edge past the first in a parallel-edge group
841
+ * (`edge.parallelLane.index > 0`): `determinePath` already called
842
+ * `applyLabelLine` directly for it, with a segment `buildParallelLanePath`
843
+ * chose explicitly rather than one this function's own width-based search
844
+ * could reliably re-derive from the finished lane path alone (see that
845
+ * function's doc) — re-running the search here would risk picking a
846
+ * different, sibling-colliding segment instead.
847
+ */
848
+ export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void {
849
+ if (edge.text.length === 0) return
850
+ if (edge.parallelLane && edge.parallelLane.index > 0) return
851
+
852
+ const lenLabel = displayWidth(edge.text)
853
+
854
+ // Every routed path is axis-aligned (A* is 4-directional and
855
+ // tryDirectPath builds explicit L-shapes), with one exception:
856
+ // determinePath's Case-4 direct fallback, `[prefFrom, prefTo]`, which is
857
+ // a single *diagonal* segment. draw-lines.ts's drawLine never draws a
858
+ // diagonal — it draws that segment as an L, horizontal-first (along
859
+ // from.y to to.x, then down/up to to.y). A label centered on the
860
+ // diagonal itself lands mid-way between the two legs, in open grid that
861
+ // has nothing to do with this edge and is often right next to some
862
+ // *other* edge's connector (#418, "All Edge Styles": the `thick` label
863
+ // abutting the dotted edge's column). Expand any diagonal segment into
864
+ // the same two legs drawLine will draw, so the label search below only
865
+ // ever considers line segments that actually get drawn.
866
+ const points = expandDiagonalSegments(edge.path)
867
+ const pathLen = points.length
868
+
869
+ // Collect all segments with their widths and orientation
870
+ const segments: {
871
+ line: [GridCoord, GridCoord]
872
+ width: number
873
+ index: number
874
+ isVertical: boolean
875
+ }[] = []
876
+
877
+ for (let i = 1; i < pathLen; i++) {
878
+ const p1 = points[i - 1]!
879
+ const p2 = points[i]!
880
+ const line: [GridCoord, GridCoord] = [p1, p2]
881
+ const width = calculateLineWidth(graph, line)
882
+ // A segment is vertical if X coords are same, horizontal if Y coords are same
883
+ const isVertical = p1.x === p2.x
884
+ segments.push({ line, width, index: i, isVertical })
885
+ }
886
+
887
+ // A segment whose *interior* (every cell strictly between its own two
888
+ // endpoints — not the endpoints themselves, which are expected to touch
889
+ // a node border or a path corner) passes through a cell owned by some
890
+ // node's 3x3 block is a bad place for a label: the label text would be
891
+ // drawn across that node's own box-drawing characters instead of open
892
+ // grid. An ordinary A*/direct-routed path never does this (routing
893
+ // itself avoids node-occupied cells), but edge-routing.ts's parallel
894
+ // lane paths (buildParallelLanePath, for true multi-edges — see #329)
895
+ // are constructed directly rather than pathfound, and their short jog
896
+ // back into the target can otherwise look, by the width-only heuristic
897
+ // below, like the best available segment even though it runs straight
898
+ // across the target's own border row/column.
899
+ const clearOfNodes = (line: [GridCoord, GridCoord]): boolean =>
900
+ interiorCellsClearOfNodes(graph, pathCells(line))
901
+
902
+ // A segment is "terminal" when one of its own two endpoints IS the
903
+ // source's or target's box-border attachment point — i.e. it's the very
904
+ // first or very last segment of the path. `clearOfNodes` above can't
905
+ // catch a bad terminal segment on its own: it only inspects *interior*
906
+ // cells (by design — a path's endpoints are expected to touch a node
907
+ // border), so a short, node-adjacent segment with no interior cells at
908
+ // all (e.g. the single-grid-step box-start connector straight into a
909
+ // node) sails through as "clear" even though its own endpoint sits
910
+ // exactly on that node's border column/row. Centering a label on such a
911
+ // segment lands the text right where the border glyph (or box-start
912
+ // connector) is drawn, silently erasing it (#450) — the label canvas is
913
+ // merged on top of the node box canvas, so whichever writes last wins.
914
+ // Excluding both terminal segments (not just the first, as before) from
915
+ // the primary and "later" fallback tiers below keeps a label away from
916
+ // either node's border, symmetric with how the first segment was already
917
+ // excluded for being "often shared between edges from the same source
918
+ // node".
919
+ const isTerminalSegment = (s: (typeof segments)[number]): boolean =>
920
+ s.index === 1 || s.index === segments.length
921
+
922
+ // Find segments wide enough for the label, excluding terminal segments
923
+ // (the first, often shared between edges from the same source node; and
924
+ // the last, which is the box-start connector straight into the target —
925
+ // see isTerminalSegment above).
926
+ const suitableSegments = segments.filter(
927
+ (s) => s.width >= lenLabel && !isTerminalSegment(s) && clearOfNodes(s.line),
928
+ )
929
+
930
+ let largestLine: [GridCoord, GridCoord]
931
+
932
+ if (suitableSegments.length > 0) {
933
+ // Prefer segments near the end of the path (closer to target)
934
+ // This avoids the shared initial segments from source
935
+ suitableSegments.sort((a, b) => b.index - a.index)
936
+ largestLine = suitableSegments[0]!.line
937
+ } else {
938
+ // Fall back to any suitable segment, including a terminal one (the
939
+ // first or last — see isTerminalSegment above): a very short path with
940
+ // no non-terminal segment at all has nothing better to offer, and a
941
+ // label landing on a terminal segment's *interior* is still preferable
942
+ // to no label at all. (A terminal segment with literally zero interior
943
+ // cells — the box-start-connector case #450 was filed against — can
944
+ // still slip through here; that's an accepted last-resort trade-off,
945
+ // not a regression, since this tier only runs once every non-terminal
946
+ // segment has already been ruled out.)
947
+ const fallbackSegments = segments.filter(
948
+ (s) => s.width >= lenLabel && clearOfNodes(s.line),
949
+ )
950
+ if (fallbackSegments.length > 0) {
951
+ fallbackSegments.sort((a, b) => b.index - a.index)
952
+ largestLine = fallbackSegments[0]!.line
953
+ } else {
954
+ // No segment both wide enough and clear of nodes — prefer the
955
+ // widest segment that's at least clear of nodes (its column can
956
+ // still be widened below to fit the label). Still prefer a
957
+ // non-terminal segment here too: a multi-segment lane path
958
+ // (buildParallelLanePath) can have every one of its later segments
959
+ // come up too narrow for this tier (e.g. a single-gutter-column
960
+ // "gutter" candidate — see that function's doc), and without this,
961
+ // the widest-clear-segment sort below would happily fall back to the
962
+ // *first* segment purely because it's typically the widest (it's on
963
+ // the shared trunk row every sibling edge from the same source
964
+ // uses) — reintroducing the exact same-row label collision this
965
+ // whole terminal-segment exclusion exists to prevent, just via this
966
+ // tier instead of the ones above. Only fall back further — first to
967
+ // *any* clear segment regardless of terminal-ness, then to every
968
+ // segment — when nothing clear survives even that relaxation (a
969
+ // single-segment edge with no room to exclude anything, or a segment
970
+ // that runs through a node with no alternative at all).
971
+ const clearLaterSegments = segments.filter(
972
+ (s) => !isTerminalSegment(s) && clearOfNodes(s.line),
973
+ )
974
+ const clearSegments = segments.filter((s) => clearOfNodes(s.line))
975
+ const pool =
976
+ clearLaterSegments.length > 0
977
+ ? clearLaterSegments
978
+ : clearSegments.length > 0
979
+ ? clearSegments
980
+ : segments
981
+ pool.sort((a, b) => b.width - a.width)
982
+ if (pool.length > 0) {
983
+ largestLine = pool[0]!.line
984
+ } else {
985
+ // No segments at all: edge.path has fewer than 2 points. This
986
+ // happens when a routed edge's preferred from/to grid coordinates
987
+ // coincide (e.g. closely-spaced/adjacent nodes), so getPath (see
988
+ // pathfinder.ts) returns a single-point path. Treat it as a
989
+ // degenerate zero-length line at that point instead of indexing
990
+ // past the end of a 1-element (or empty) array.
991
+ const only = edge.path[0] ?? { x: 0, y: 0 }
992
+ largestLine = [only, only]
993
+ }
994
+ }
995
+ }
996
+
997
+ applyLabelLine(graph, edge, largestLine, lenLabel)
998
+ }
999
+
1000
+ /**
1001
+ * Replace every diagonal step in `path` (consecutive points that differ in
1002
+ * both x and y) with the two axis-aligned legs draw-lines.ts's drawLine
1003
+ * actually draws for it: horizontal first, to the corner `{x: next.x,
1004
+ * y: prev.y}`, then vertical. Axis-aligned steps pass through unchanged, so
1005
+ * an ordinary routed path comes back identical. Only determinePath's Case-4
1006
+ * direct fallback produces a diagonal in practice — see determineLabelLine.
1007
+ */
1008
+ export function expandDiagonalSegments(path: GridCoord[]): GridCoord[] {
1009
+ const out: GridCoord[] = []
1010
+ for (const point of path) {
1011
+ const prev = out[out.length - 1]
1012
+ if (prev && prev.x !== point.x && prev.y !== point.y) {
1013
+ out.push({ x: point.x, y: prev.y })
1014
+ }
1015
+ out.push(point)
1016
+ }
1017
+ return out
1018
+ }
1019
+
1020
+ /**
1021
+ * Finish assigning a label to `line`: widen whichever column its midpoint
1022
+ * falls in enough to fit the label, then commit `edge.labelLine`. Shared
1023
+ * tail for `determineLabelLine`'s own segment-search heuristic above and
1024
+ * `buildParallelLanePath`'s explicit segment choice below — the latter
1025
+ * already knows exactly which segment is correct for its own lane (see
1026
+ * that function's doc for why the heuristic above can't reliably infer it
1027
+ * for a parallel-lane path), so it skips straight to this shared finish
1028
+ * instead of going through the search.
1029
+ *
1030
+ * The chosen column must not be one a node itself occupies (its 3-column
1031
+ * border/content/border block, reserved in reserveSpotInGrid): a shared
1032
+ * trunk segment often passes directly over/adjacent to another node's
1033
+ * reserved columns on its way elsewhere, and if the label's midpoint
1034
+ * happens to land there, widening it to fit the label inflates that
1035
+ * node's own border-column width — which then drags things anchored to
1036
+ * that column's *center* (like the box-start ├/┤/┬/┴ connector in
1037
+ * draw-arrows.ts, computed via gridToDrawingCoord) away from the node's
1038
+ * actual, fixed-width rendered border. Search outward from the ideal
1039
+ * midpoint for the nearest column in the segment that isn't node-owned.
1040
+ */
1041
+ function applyLabelLine(
1042
+ graph: AsciiGraph,
1043
+ edge: AsciiEdge,
1044
+ line: [GridCoord, GridCoord],
1045
+ lenLabel: number,
1046
+ ): void {
1047
+ const minX = Math.min(line[0].x, line[1].x)
1048
+ const maxX = Math.max(line[0].x, line[1].x)
1049
+ const idealX = minX + Math.floor((maxX - minX) / 2)
1050
+ const middleX = findNonNodeColumn(graph, minX, maxX, idealX)
1051
+
1052
+ const current = graph.columnWidth.get(middleX) ?? 0
1053
+ graph.columnWidth.set(middleX, Math.max(current, lenLabel + 2))
1054
+
1055
+ edge.labelLine = [line[0], line[1]]
1056
+ }
1057
+
1058
+ /** Calculate the total character width of a line segment by summing column widths. */
1059
+ function calculateLineWidth(
1060
+ graph: AsciiGraph,
1061
+ line: [GridCoord, GridCoord],
1062
+ ): number {
1063
+ let total = 0
1064
+ const startX = Math.min(line[0].x, line[1].x)
1065
+ const endX = Math.max(line[0].x, line[1].x)
1066
+ for (let x = startX; x <= endX; x++) {
1067
+ total += graph.columnWidth.get(x) ?? 0
1068
+ }
1069
+ return total
1070
+ }