@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,593 @@
1
+ // ============================================================================
2
+ // ASCII renderer — arrow drawing (path, corners, arrowheads, box-start
3
+ // junctions, labels)
4
+ //
5
+ // Split out of draw.ts.
6
+ // ============================================================================
7
+
8
+ import type {
9
+ Canvas,
10
+ DrawingCoord,
11
+ GridCoord,
12
+ Direction,
13
+ AsciiGraph,
14
+ AsciiEdge,
15
+ AsciiEdgeStyle,
16
+ AsciiNode,
17
+ } from './types.ts'
18
+ import {
19
+ Up,
20
+ Down,
21
+ Left,
22
+ Right,
23
+ UpperLeft,
24
+ UpperRight,
25
+ LowerLeft,
26
+ LowerRight,
27
+ Middle,
28
+ drawingCoordEquals,
29
+ } from './types.ts'
30
+ import { copyCanvas, drawText, write } from './canvas.ts'
31
+ import { determineDirection, dirEquals } from './edge-routing.ts'
32
+ import { displayWidth } from './display-width.ts'
33
+ import { gridToDrawingCoord, lineToDrawing } from './grid.ts'
34
+ import { splitLines } from './multiline-utils.ts'
35
+ import { drawLine } from './draw-lines.ts'
36
+
37
+ /**
38
+ * Draw a complete arrow (edge) between two nodes.
39
+ * Returns 6 separate canvases for layered compositing:
40
+ * [path, boxStart, arrowHeadEnd, arrowHeadStart, corners, label]
41
+ *
42
+ * Supports bidirectional arrows via edge.hasArrowStart and edge.hasArrowEnd.
43
+ */
44
+ export function drawArrow(
45
+ graph: AsciiGraph,
46
+ edge: AsciiEdge,
47
+ ): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] {
48
+ if (edge.path.length === 0) {
49
+ const empty = copyCanvas(graph.canvas)
50
+ return [empty, empty, empty, empty, empty, empty]
51
+ }
52
+
53
+ const labelCanvas = drawArrowLabel(graph, edge)
54
+ const [pathCanvas, linesDrawn, lineDirs] = drawPath(
55
+ graph,
56
+ edge.path,
57
+ edge.style,
58
+ )
59
+
60
+ // A routed path can collapse to zero drawn line segments when every grid
61
+ // point maps to the same drawing coordinate — e.g. a routed edge whose
62
+ // preferred from/to grid coordinates coincide for closely-spaced/adjacent
63
+ // nodes (pathfinder.ts's getPath can legitimately return a single-point
64
+ // path in that case; see #153). With no segment to anchor to, there's
65
+ // nothing for a box-start connector or an end arrowhead to attach to, so
66
+ // both are skipped instead of indexing into the empty linesDrawn/lineDirs
67
+ // arrays.
68
+ const hasSegments = linesDrawn.length > 0
69
+
70
+ /*
71
+ * An invisible link (`A ~~~ B`) routes and reserves space like any other
72
+ * edge, but must leave no mark. drawPath already writes spaces for its
73
+ * segments; the box connector and corner glyphs are drawn by separate
74
+ * passes that don't consult the line character set, so they're suppressed
75
+ * explicitly here. Arrowheads need no guard — `~~~` carries no `>`/`o`/`x`
76
+ * marker, so hasArrowStart/hasArrowEnd are already false.
77
+ */
78
+ const invisible = edge.style === 'invisible'
79
+
80
+ const boxStartCanvas =
81
+ hasSegments && !invisible
82
+ ? drawBoxStart(graph, edge.path, linesDrawn[0]!, edge.from)
83
+ : copyCanvas(graph.canvas)
84
+
85
+ // Draw end arrowhead only if hasArrowEnd is true (default behavior)
86
+ let arrowHeadEndCanvas: Canvas
87
+ if (edge.hasArrowEnd && hasSegments) {
88
+ arrowHeadEndCanvas = drawArrowHead(
89
+ graph,
90
+ linesDrawn[linesDrawn.length - 1]!,
91
+ lineDirs[lineDirs.length - 1]!,
92
+ edge.endMarker,
93
+ )
94
+ } else {
95
+ arrowHeadEndCanvas = copyCanvas(graph.canvas)
96
+ }
97
+
98
+ // Draw start arrowhead for bidirectional edges
99
+ // The start arrowhead needs to be at the box connector position (one step back
100
+ // from the first line point), pointing into the source node.
101
+ let arrowHeadStartCanvas: Canvas
102
+ if (edge.hasArrowStart && hasSegments) {
103
+ const firstLine = linesDrawn[0]!
104
+ const firstPoint = firstLine[0]!
105
+ const startDir = reverseDirection(lineDirs[0]!)
106
+
107
+ // Calculate the box connector position (one step back from first point)
108
+ const arrowPos: DrawingCoord = { x: firstPoint.x, y: firstPoint.y }
109
+ if (dirEquals(lineDirs[0]!, Right)) arrowPos.x = firstPoint.x - 1
110
+ else if (dirEquals(lineDirs[0]!, Left)) arrowPos.x = firstPoint.x + 1
111
+ else if (dirEquals(lineDirs[0]!, Down)) arrowPos.y = firstPoint.y - 1
112
+ else if (dirEquals(lineDirs[0]!, Up)) arrowPos.y = firstPoint.y + 1
113
+
114
+ // Create a synthetic line ending at the arrow position for drawArrowHead
115
+ const syntheticLine: DrawingCoord[] = [firstPoint, arrowPos]
116
+ arrowHeadStartCanvas = drawArrowHead(
117
+ graph,
118
+ syntheticLine,
119
+ startDir,
120
+ edge.startMarker,
121
+ )
122
+ } else {
123
+ arrowHeadStartCanvas = copyCanvas(graph.canvas)
124
+ }
125
+
126
+ const cornersCanvas = invisible
127
+ ? copyCanvas(graph.canvas)
128
+ : drawCorners(graph, edge.path)
129
+
130
+ return [
131
+ pathCanvas,
132
+ boxStartCanvas,
133
+ arrowHeadEndCanvas,
134
+ arrowHeadStartCanvas,
135
+ cornersCanvas,
136
+ labelCanvas,
137
+ ]
138
+ }
139
+
140
+ /**
141
+ * Reverse a direction (for bidirectional arrow start heads).
142
+ */
143
+ function reverseDirection(dir: Direction): Direction {
144
+ if (dirEquals(dir, Up)) return Down
145
+ if (dirEquals(dir, Down)) return Up
146
+ if (dirEquals(dir, Left)) return Right
147
+ if (dirEquals(dir, Right)) return Left
148
+ if (dirEquals(dir, UpperLeft)) return LowerRight
149
+ if (dirEquals(dir, UpperRight)) return LowerLeft
150
+ if (dirEquals(dir, LowerLeft)) return UpperRight
151
+ if (dirEquals(dir, LowerRight)) return UpperLeft
152
+ return Middle
153
+ }
154
+
155
+ /**
156
+ * Draw the path lines for an edge.
157
+ * Returns the canvas, the coordinates drawn for each segment, and the direction of each segment.
158
+ */
159
+ function drawPath(
160
+ graph: AsciiGraph,
161
+ path: GridCoord[],
162
+ style: AsciiEdgeStyle = 'solid',
163
+ ): [Canvas, DrawingCoord[][], Direction[]] {
164
+ const canvas = copyCanvas(graph.canvas)
165
+ // path is non-empty: drawArrow (drawPath's sole caller) already returns
166
+ // early when edge.path.length === 0.
167
+ let previousCoord = path[0]!
168
+ const linesDrawn: DrawingCoord[][] = []
169
+ const lineDirs: Direction[] = []
170
+
171
+ for (let i = 1; i < path.length; i++) {
172
+ const nextCoord = path[i]!
173
+ const prevDC = gridToDrawingCoord(graph, previousCoord)
174
+ const nextDC = gridToDrawingCoord(graph, nextCoord)
175
+
176
+ if (drawingCoordEquals(prevDC, nextDC)) {
177
+ previousCoord = nextCoord
178
+ continue
179
+ }
180
+
181
+ const dir = determineDirection(previousCoord, nextCoord)
182
+ const segment = drawLine(
183
+ canvas,
184
+ prevDC,
185
+ nextDC,
186
+ 1,
187
+ -1,
188
+ graph.config.useAscii,
189
+ style,
190
+ )
191
+ if (segment.length === 0) segment.push(prevDC)
192
+ linesDrawn.push(segment)
193
+ lineDirs.push(dir)
194
+ previousCoord = nextCoord
195
+ }
196
+
197
+ return [canvas, linesDrawn, lineDirs]
198
+ }
199
+
200
+ /** Vertical box-border characters a horizontal connector can genuinely merge with. */
201
+ const VERTICAL_BORDER_CHARS = new Set(['│', '┃', '║', '┆', '┊', '|', '‖'])
202
+ /** Horizontal box-border characters a vertical connector can genuinely merge with. */
203
+ const HORIZONTAL_BORDER_CHARS = new Set(['─', '━', '═', '╌', '┄', '-', '='])
204
+
205
+ /**
206
+ * Draw the junction character where an edge exits the source node's box.
207
+ * Unicode mode uses shape-specific T-junction glyphs; ASCII mode uses the
208
+ * same universal '+' already used for corners in draw-boxes.ts.
209
+ * Skips drawing for state pseudo-states which have their own visual borders.
210
+ *
211
+ * A tee/junction character (┬┴├┤) is only correct when it's actually merging
212
+ * with a real perpendicular border line at that exact cell — e.g. a grid
213
+ * column widened by a sibling edge's label can push the computed box-start
214
+ * position away from the node's actual border, landing on a blank cell with
215
+ * no vertical (or horizontal) line to justify a junction. In that case fall
216
+ * back to a plain line character matching the edge's own direction, instead
217
+ * of fabricating a stray tee (see issue #86).
218
+ */
219
+ function drawBoxStart(
220
+ graph: AsciiGraph,
221
+ path: GridCoord[],
222
+ firstLine: DrawingCoord[],
223
+ sourceNode: AsciiNode,
224
+ ): Canvas {
225
+ const canvas = copyCanvas(graph.canvas)
226
+ const useAscii = graph.config.useAscii
227
+
228
+ // Skip box start connectors for state pseudo-states (they have their own bordered design)
229
+ if (sourceNode.shape === 'state-start' || sourceNode.shape === 'state-end') {
230
+ return canvas
231
+ }
232
+
233
+ // firstLine is guaranteed non-empty and path has >= 2 points: the sole
234
+ // caller (drawArrow) only invokes drawBoxStart when drawPath produced at
235
+ // least one line segment (see the `hasSegments` guard there), and
236
+ // drawPath never pushes an empty segment into linesDrawn.
237
+ const from = firstLine[0]!
238
+ const dir = determineDirection(path[0]!, path[1]!)
239
+ const junction = useAscii ? '+' : null
240
+
241
+ // `canvas` (from copyCanvas) is a blank overlay layer — it never has
242
+ // content of its own to check. Whether a genuine perpendicular border
243
+ // line already occupies the target cell has to be read from
244
+ // `graph.canvas`, which at this point in drawGraph already has node
245
+ // boxes (and subgraph borders) merged onto it, before any edge layers.
246
+ const existingOnBox = (x: number, y: number): string | undefined =>
247
+ graph.canvas[x]?.[y]
248
+
249
+ if (dirEquals(dir, Up)) {
250
+ const x = from.x
251
+ const y = from.y + 1
252
+ const existing = existingOnBox(x, y)
253
+ const hasBorder =
254
+ existing !== undefined && HORIZONTAL_BORDER_CHARS.has(existing)
255
+ write(canvas, x, y, hasBorder ? (junction ?? '┴') : useAscii ? '|' : '│')
256
+ } else if (dirEquals(dir, Down)) {
257
+ const x = from.x
258
+ const y = from.y - 1
259
+ const existing = existingOnBox(x, y)
260
+ const hasBorder =
261
+ existing !== undefined && HORIZONTAL_BORDER_CHARS.has(existing)
262
+ write(canvas, x, y, hasBorder ? (junction ?? '┬') : useAscii ? '|' : '│')
263
+ } else if (dirEquals(dir, Left) || dirEquals(dir, Right)) {
264
+ // Anchor horizontal connectors to the source node's *own* rendered
265
+ // border column, not to gridToDrawingCoord's grid-column-centered
266
+ // position. That position is centered within the node's border grid
267
+ // column using that column's *allocated* width — but a sibling edge's
268
+ // label can land on that same column (its labelLine's chosen segment
269
+ // just happens to pass through the node's border column on its way
270
+ // elsewhere) and widen it well past the 1-character width the border
271
+ // itself needs. Centering on the inflated width then drags the
272
+ // connector away from the box's actual border character, which stays
273
+ // put at a position that only depends on the box's own dimensions.
274
+ //
275
+ // Node dimensions are set before edge routing/drawing (see
276
+ // createMapping in grid.ts), so drawingCoord/drawing should always be
277
+ // present here — but that's a cross-module invariant the type checker
278
+ // can't see, so it's validated explicitly rather than trusted silently.
279
+ const dc = sourceNode.drawingCoord
280
+ const drawing = sourceNode.drawing
281
+ if (dc === null || drawing === null) {
282
+ /* v8 ignore next */
283
+ throw new Error(
284
+ `drawBoxStart: node "${sourceNode.name}" has no drawingCoord/drawing assigned`,
285
+ )
286
+ }
287
+ const boxWidth = drawing.length
288
+ const x = dirEquals(dir, Left) ? dc.x : dc.x + boxWidth - 1
289
+ const y = from.y
290
+ const existing = existingOnBox(x, y)
291
+ const hasBorder =
292
+ existing !== undefined && VERTICAL_BORDER_CHARS.has(existing)
293
+ write(
294
+ canvas,
295
+ x,
296
+ y,
297
+ hasBorder
298
+ ? (junction ?? (dirEquals(dir, Left) ? '┤' : '├'))
299
+ : useAscii
300
+ ? '-'
301
+ : '─',
302
+ )
303
+ }
304
+
305
+ return canvas
306
+ }
307
+
308
+ /**
309
+ * Fixed glyph for a `--o`/`--x` circle/cross terminator — direction-
310
+ * independent, unlike the triangular arrowheads below, so callers don't
311
+ * need to know which way the edge points to pick it. Returns undefined for
312
+ * a plain arrowhead (no marker), so a caller can fall back to its own
313
+ * directional glyph selection. Shared by drawArrowHead below (single-edge
314
+ * arrowheads) and draw-bundles.ts (fan-in/fan-out bundled arrowheads) so
315
+ * the glyph choice can't drift between the two. See issue #330.
316
+ */
317
+ export function markerArrowChar(
318
+ useAscii: boolean,
319
+ marker: 'circle' | 'cross' | undefined,
320
+ ): string | undefined {
321
+ if (marker === 'circle') return useAscii ? 'o' : '○'
322
+ if (marker === 'cross') return useAscii ? 'x' : '✕'
323
+ return undefined
324
+ }
325
+
326
+ /**
327
+ * Maps a direction to its Unicode arrowhead glyph, or `undefined` for a
328
+ * direction with no arrowhead (`Middle`, or any other value this switch
329
+ * doesn't recognize) — callers decide the ultimate fallback.
330
+ *
331
+ * Uses triangular Unicode symbols (▲▼◄►) for the four orthogonal
332
+ * directions and diagonal arrow symbols (↗↖↘↙) for the four diagonal ones.
333
+ * The diagonal glyphs are plain Arrows-block characters (U+2196–U+2199),
334
+ * not filled triangles like the orthogonal set — JetBrains Mono NL (this
335
+ * site's self-hosted ASCII font, see scripts/build-mono-font-subset.ts)
336
+ * has no glyph at all for the filled diagonal triangles (◢◣◤◥, U+25E2–
337
+ * U+25E5) that would otherwise match the orthogonal style; U+2196–U+2199
338
+ * are real, correctly-directional glyphs the font does have, which beats
339
+ * falling back to an unpinned system font for just these four characters.
340
+ * See issue #1062.
341
+ *
342
+ * Exported (alongside {@link asciiArrowChar}) so tests can exercise every
343
+ * direction directly — the ELK/pathfinder layout this module actually
344
+ * draws from rarely if ever produces some direction combinations (e.g.
345
+ * `LowerLeft` only arises from determinePath's rare Case-4 diagonal
346
+ * fallback, and only for a specific relative source/target position no
347
+ * existing sample or hand-written diagram happens to trigger), which would
348
+ * otherwise leave this mapping's correctness for those directions unverified.
349
+ */
350
+ export function unicodeArrowChar(dir: Direction): string | undefined {
351
+ if (dirEquals(dir, Up)) return '▲'
352
+ if (dirEquals(dir, Down)) return '▼'
353
+ if (dirEquals(dir, Left)) return '◄'
354
+ if (dirEquals(dir, Right)) return '►'
355
+ if (dirEquals(dir, UpperRight)) return '↗'
356
+ if (dirEquals(dir, UpperLeft)) return '↖'
357
+ if (dirEquals(dir, LowerRight)) return '↘'
358
+ if (dirEquals(dir, LowerLeft)) return '↙'
359
+ return undefined
360
+ }
361
+
362
+ /** ASCII-mode counterpart of {@link unicodeArrowChar} — orthogonal only, no diagonals. */
363
+ export function asciiArrowChar(dir: Direction): string | undefined {
364
+ if (dirEquals(dir, Up)) return '^'
365
+ if (dirEquals(dir, Down)) return 'v'
366
+ if (dirEquals(dir, Left)) return '<'
367
+ if (dirEquals(dir, Right)) return '>'
368
+ return undefined
369
+ }
370
+
371
+ /**
372
+ * Draw the arrowhead at the end of an edge path.
373
+ *
374
+ * `marker` overrides the directional glyph with a fixed circle/cross glyph
375
+ * for `--o`/`--x` (flowchart) terminators — those are direction-
376
+ * independent, so the direction computed below is only used to place it,
377
+ * never to pick which glyph to draw. See issue #330.
378
+ */
379
+ function drawArrowHead(
380
+ graph: AsciiGraph,
381
+ lastLine: DrawingCoord[],
382
+ fallbackDir: Direction,
383
+ marker?: 'circle' | 'cross',
384
+ ): Canvas {
385
+ const canvas = copyCanvas(graph.canvas)
386
+ if (lastLine.length === 0) return canvas
387
+
388
+ // Direction is derived from the *final step* into `lastPos` (its
389
+ // immediate predecessor in `lastLine`), not from `lastLine`'s first
390
+ // point. Those coincide for an ordinary straight segment, but
391
+ // `determinePath`'s Case-4 diagonal fallback (edge-routing.ts) can hand
392
+ // drawLine a single non-axis-aligned pair, which it then draws as an L
393
+ // (horizontal run, then vertical run — see draw-lines.ts) folded into
394
+ // one `lastLine` array. Using the first point there would span both
395
+ // legs and read as diagonal even when the actual approach into the
396
+ // arrowhead is a plain orthogonal step. See issue #1083.
397
+ const lastPos = lastLine[lastLine.length - 1]!
398
+ const from =
399
+ lastLine.length >= 2 ? lastLine[lastLine.length - 2]! : lastLine[0]!
400
+ let dir = determineDirection(from, lastPos)
401
+ if (lastLine.length === 1 || dirEquals(dir, Middle)) dir = fallbackDir
402
+
403
+ const markerChar = markerArrowChar(graph.config.useAscii, marker)
404
+ let char: string
405
+ if (markerChar !== undefined) {
406
+ char = markerChar
407
+ } else if (!graph.config.useAscii) {
408
+ const resolved = unicodeArrowChar(dir)
409
+ // `resolved` is only ever undefined when `dir` itself is `Middle` (or
410
+ // an unrecognized Direction) — which, given the reassignment above,
411
+ // only happens when `fallbackDir` is itself `Middle`. No current
412
+ // caller passes that; every drawArrow/draw-bundles.ts call site
413
+ // computes a real directional fallback. Kept as a safety net rather
414
+ // than a hard assumption.
415
+ /* v8 ignore else */
416
+ if (resolved !== undefined) {
417
+ char = resolved
418
+ } else {
419
+ char = unicodeArrowChar(fallbackDir) ?? '●'
420
+ }
421
+ } else {
422
+ const resolved = asciiArrowChar(dir)
423
+ // Same defensive-only fallback as the unicode-mode branch above,
424
+ // mirrored for ASCII mode.
425
+ /* v8 ignore else */
426
+ if (resolved !== undefined) {
427
+ char = resolved
428
+ } else {
429
+ char = asciiArrowChar(fallbackDir) ?? '*'
430
+ }
431
+ }
432
+
433
+ write(canvas, lastPos.x, lastPos.y, char)
434
+ return canvas
435
+ }
436
+
437
+ /**
438
+ * Draw corner characters at path bends (where the direction changes).
439
+ * Uses ┌┐└┘ in Unicode mode, + in ASCII mode.
440
+ */
441
+ function drawCorners(graph: AsciiGraph, path: GridCoord[]): Canvas {
442
+ const canvas = copyCanvas(graph.canvas)
443
+
444
+ for (let idx = 1; idx < path.length - 1; idx++) {
445
+ const coord = path[idx]!
446
+ const dc = gridToDrawingCoord(graph, coord)
447
+ const prevDir = determineDirection(path[idx - 1]!, coord)
448
+ const nextDir = determineDirection(coord, path[idx + 1]!)
449
+
450
+ let corner: string
451
+ if (!graph.config.useAscii) {
452
+ if (
453
+ (dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||
454
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))
455
+ ) {
456
+ corner = '┐'
457
+ } else if (
458
+ (dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||
459
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))
460
+ ) {
461
+ corner = '┘'
462
+ } else if (
463
+ (dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||
464
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))
465
+ ) {
466
+ corner = '┌'
467
+ } else if (
468
+ (dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||
469
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))
470
+ ) {
471
+ corner = '└'
472
+ } else {
473
+ corner = '+'
474
+ }
475
+ } else {
476
+ corner = '+'
477
+ }
478
+
479
+ write(canvas, dc.x, dc.y, corner)
480
+ }
481
+
482
+ return canvas
483
+ }
484
+
485
+ /**
486
+ * True when `edge` has a sibling edge connecting the exact same two nodes in
487
+ * the opposite direction (`A --> B` alongside `B --> A`) — a "reciprocal
488
+ * pair". Node identity is compared by reference: `converter.ts` resolves
489
+ * every edge's `from`/`to` from the same shared node map, so the same
490
+ * logical node is always the same object across edges.
491
+ */
492
+ function hasReciprocalPartner(graph: AsciiGraph, edge: AsciiEdge): boolean {
493
+ return graph.edges.some(
494
+ (other) =>
495
+ other !== edge && other.from === edge.to && other.to === edge.from,
496
+ )
497
+ }
498
+
499
+ /** Draw edge label text centered on the widest path segment. */
500
+ function drawArrowLabel(graph: AsciiGraph, edge: AsciiEdge): Canvas {
501
+ const canvas = copyCanvas(graph.canvas)
502
+ if (edge.text.length === 0) return canvas
503
+
504
+ const drawingLine = lineToDrawing(graph, edge.labelLine)
505
+
506
+ // Determine if this is an upward edge (target is above source in the path)
507
+ // This is used to offset labels on bidirectional edges to prevent overlap
508
+ let isUpwardEdge: boolean | undefined
509
+ if (edge.path.length >= 2) {
510
+ const startY = edge.path[0]!.y
511
+ const endY = edge.path[edge.path.length - 1]!.y
512
+ // Edge goes up if end Y is less than start Y (smaller Y = higher on screen)
513
+ if (endY < startY) {
514
+ isUpwardEdge = true
515
+ } else if (endY > startY) {
516
+ isUpwardEdge = false
517
+ }
518
+ // If endY === startY, it's horizontal, leave isUpwardEdge undefined
519
+ }
520
+
521
+ // Only a genuine reciprocal pair (A-->B alongside B-->A) needs its label
522
+ // pulled toward its own target instead of its own source — see #530 and
523
+ // drawTextOnLine's doc comment below. A lone vertical edge keeps the
524
+ // original "precede the arrow, near the source" placement.
525
+ const pullTowardTarget = hasReciprocalPartner(graph, edge)
526
+
527
+ drawTextOnLine(canvas, drawingLine, edge.text, isUpwardEdge, pullTowardTarget)
528
+ return canvas
529
+ }
530
+
531
+ /**
532
+ * Draw text centered on a line segment defined by two drawing coordinates.
533
+ * Supports multi-line labels.
534
+ *
535
+ * When isUpwardEdge is provided, offsets the label vertically to prevent
536
+ * overlapping with labels from edges going the opposite direction:
537
+ * - Upward edges: label placed in lower portion of segment (near its own
538
+ * source), unless `pullTowardTarget` is set — see below.
539
+ * - Downward edges (isUpwardEdge=false): label placed in upper portion
540
+ * (near its own source), unless `pullTowardTarget` is set.
541
+ * - No direction (isUpwardEdge=undefined): label centered (default)
542
+ *
543
+ * `pullTowardTarget` inverts both of the above, pulling the label toward
544
+ * its own arrowhead (the edge's target end) instead of its source. This
545
+ * only makes a visible difference for a genuine reciprocal pair sharing one
546
+ * vertical channel (`A --> B` alongside `B --> A`, both routed through the
547
+ * same column): pulling each label toward its own *source* there pulls it
548
+ * right next to the *other* edge's arrowhead instead, since in a two-node
549
+ * cycle one edge's source is the other edge's target. #530 is exactly that
550
+ * bug — a same-pair bidirectional edge's two labels rendered swapped
551
+ * relative to the arrowheads they sit beside. A lone edge (no reciprocal
552
+ * partner) keeps the original near-source placement so its label still
553
+ * reads as "preceding" its own arrow rather than crowding the arrowhead.
554
+ */
555
+ function drawTextOnLine(
556
+ canvas: Canvas,
557
+ line: DrawingCoord[],
558
+ label: string,
559
+ isUpwardEdge?: boolean,
560
+ pullTowardTarget = false,
561
+ ): void {
562
+ if (line.length < 2) return
563
+ const minX = Math.min(line[0]!.x, line[1]!.x)
564
+ const maxX = Math.max(line[0]!.x, line[1]!.x)
565
+ const minY = Math.min(line[0]!.y, line[1]!.y)
566
+ const maxY = Math.max(line[0]!.y, line[1]!.y)
567
+ const middleX = minX + Math.floor((maxX - minX) / 2)
568
+ let middleY = minY + Math.floor((maxY - minY) / 2)
569
+
570
+ // Offset label vertically to prevent overlap on bidirectional edges
571
+ // For vertical segments (same X), shift based on edge direction
572
+ if (isUpwardEdge !== undefined && minX === maxX) {
573
+ const segmentHeight = maxY - minY
574
+ const offset = Math.max(1, Math.floor(segmentHeight / 4))
575
+ // XOR: pullTowardTarget flips which portion each direction lands in.
576
+ const towardMinY = pullTowardTarget ? isUpwardEdge : !isUpwardEdge
577
+ if (towardMinY) {
578
+ middleY = middleY - offset
579
+ } else {
580
+ middleY = middleY + offset
581
+ }
582
+ }
583
+
584
+ // Support multi-line labels
585
+ const lines = splitLines(label)
586
+ const startY = middleY - Math.floor((lines.length - 1) / 2)
587
+
588
+ for (let i = 0; i < lines.length; i++) {
589
+ const lineText = lines[i]!
590
+ const startX = middleX - Math.floor(displayWidth(lineText) / 2)
591
+ drawText(canvas, { x: startX, y: startY + i }, lineText)
592
+ }
593
+ }