@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,611 @@
1
+ // ============================================================================
2
+ // ASCII renderer — bundled edge drawing (for parallel links, A & B --> C)
3
+ //
4
+ // Split out of draw.ts. Renders the fan-in/fan-out bundles computed by
5
+ // ./edge-bundling.ts (junction points, shared trunks) onto the canvas.
6
+ // ============================================================================
7
+
8
+ import type {
9
+ Canvas,
10
+ DrawingCoord,
11
+ Direction,
12
+ AsciiGraph,
13
+ AsciiNode,
14
+ AsciiEdge,
15
+ EdgeBundle,
16
+ } from './types.ts'
17
+ import {
18
+ Up,
19
+ Down,
20
+ Left,
21
+ Right,
22
+ drawingCoordEquals,
23
+ requireGridCoord,
24
+ } from './types.ts'
25
+ import { copyCanvas, write } from './canvas.ts'
26
+ import { determineDirection, dirEquals } from './edge-routing.ts'
27
+ import { gridToDrawingCoord } from './grid.ts'
28
+ import { getShapeAttachmentPoint } from './shapes/index.ts'
29
+ import type { ShapeDimensions } from './shapes/index.ts'
30
+ import { drawLine } from './draw-lines.ts'
31
+ import { markerArrowChar } from './draw-arrows.ts'
32
+
33
+ // ============================================================================
34
+ // Node attachment point helper
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Get the drawing coordinate where an edge attaches to a node's border.
39
+ * Uses grid-allocated dimensions so attachment points align with the actual
40
+ * drawn box (which may be wider/taller than the intrinsic shape dimensions
41
+ * when sharing a column/row with a larger node).
42
+ */
43
+ function getNodeAttachmentPoint(
44
+ graph: AsciiGraph,
45
+ node: AsciiNode,
46
+ dir: Direction,
47
+ ): DrawingCoord {
48
+ // Every node reaching bundled-edge drawing has already been placed by the
49
+ // grid layout pass (see grid.ts's createMapping) — requireGridCoord
50
+ // validates that rather than trusting it silently across the module
51
+ // boundary, since the type checker can't see it.
52
+ const gc = requireGridCoord(node)
53
+
54
+ // Calculate actual drawn dimensions from grid (matching drawBoxWithGridDimensions)
55
+ let w = 0
56
+ for (let i = 0; i < 2; i++) {
57
+ w += graph.columnWidth.get(gc.x + i) ?? 0
58
+ }
59
+ let h = 0
60
+ for (let i = 0; i < 2; i++) {
61
+ h += graph.rowHeight.get(gc.y + i) ?? 0
62
+ }
63
+
64
+ // Build dimensions matching the actual drawn box size
65
+ const gridDimensions: ShapeDimensions = {
66
+ width: w + 1,
67
+ height: h + 1,
68
+ labelArea: { x: 0, y: 0, width: 0, height: 0 },
69
+ gridColumns: [0, 0, 0],
70
+ gridRows: [0, 0, 0],
71
+ }
72
+
73
+ const baseCoord = node.drawingCoord
74
+ if (baseCoord === null) {
75
+ // Same invariant as gridCoord above: drawingCoord is derived from
76
+ // gridCoord during layout, so it's set for every placed node.
77
+ /* v8 ignore next */
78
+ throw new Error(
79
+ `Node "${node.name}" has no drawingCoord; grid layout must run before bundled-edge drawing`,
80
+ )
81
+ }
82
+ return getShapeAttachmentPoint(node.shape, dir, gridDimensions, baseCoord)
83
+ }
84
+
85
+ /**
86
+ * Draw a single edge's segment in a bundle (source → junction for fan-in,
87
+ * junction → target for fan-out).
88
+ *
89
+ * Returns the same tuple format as drawArrow for consistency.
90
+ */
91
+ export function drawBundledEdgeSegment(
92
+ graph: AsciiGraph,
93
+ edge: AsciiEdge,
94
+ bundle: EdgeBundle,
95
+ ): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] {
96
+ const empty = copyCanvas(graph.canvas)
97
+
98
+ // Captured as a local so its non-null, non-empty narrowing survives into
99
+ // the closures below — narrowing a property access like
100
+ // `edge.pathToJunction` does not persist inside a callback (the compiler
101
+ // can't rule out the callback mutating it), but narrowing a local const
102
+ // does.
103
+ const pathToJunction = edge.pathToJunction
104
+ if (!pathToJunction || pathToJunction.length === 0) {
105
+ return [empty, empty, empty, empty, empty, empty]
106
+ }
107
+
108
+ // Draw the path segment (pathToJunction)
109
+ const pathCanvas = copyCanvas(graph.canvas)
110
+ const useAscii = graph.config.useAscii
111
+
112
+ // Convert grid coords to drawing coords
113
+ // For fan-in: first point is at source node border (use attachment point)
114
+ // For fan-out: last point is at target node border (use attachment point)
115
+ const drawingPath = pathToJunction.map((gc, idx) => {
116
+ if (bundle.type === 'fan-in' && idx === 0) {
117
+ // First point: use source node's actual border position
118
+ return getNodeAttachmentPoint(graph, edge.from, edge.startDir)
119
+ }
120
+ if (bundle.type === 'fan-out' && idx === pathToJunction.length - 1) {
121
+ // Last point: use target node's actual border position
122
+ return getNodeAttachmentPoint(graph, edge.to, edge.endDir)
123
+ }
124
+ return gridToDrawingCoord(graph, gc)
125
+ })
126
+
127
+ // Draw line segments
128
+ for (let i = 1; i < drawingPath.length; i++) {
129
+ const from = drawingPath[i - 1]!
130
+ const to = drawingPath[i]!
131
+ if (!drawingCoordEquals(from, to)) {
132
+ // Always skip both endpoints of every segment (offset 1, -1),
133
+ // matching non-bundled drawPath behavior. This leaves endpoint
134
+ // characters to corner/junction/boxStart canvases, preventing
135
+ // line characters from corrupting them via mergeJunctions.
136
+ drawLine(pathCanvas, from, to, 1, -1, useAscii, edge.style)
137
+ }
138
+ }
139
+
140
+ // Draw corners at path bends
141
+ const cornersCanvas = copyCanvas(graph.canvas)
142
+ for (let idx = 1; idx < pathToJunction.length - 1; idx++) {
143
+ const coord = pathToJunction[idx]!
144
+ const dc = gridToDrawingCoord(graph, coord)
145
+ const prevDir = determineDirection(pathToJunction[idx - 1]!, coord)
146
+ const nextDir = determineDirection(coord, pathToJunction[idx + 1]!)
147
+
148
+ let corner: string
149
+ if (!useAscii) {
150
+ if (
151
+ (dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||
152
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))
153
+ ) {
154
+ corner = '┐'
155
+ } else if (
156
+ (dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||
157
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))
158
+ ) {
159
+ corner = '┘'
160
+ } else if (
161
+ (dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||
162
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))
163
+ ) {
164
+ corner = '┌'
165
+ } else if (
166
+ (dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||
167
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))
168
+ ) {
169
+ corner = '└'
170
+ } else {
171
+ corner = '+'
172
+ }
173
+ } else {
174
+ corner = '+'
175
+ }
176
+
177
+ write(cornersCanvas, dc.x, dc.y, corner)
178
+ }
179
+
180
+ // Draw box start connector (for fan-in, from source node)
181
+ // The connector is placed at the first point coordinate (box border position)
182
+ // since we use offsets 1,-1 for drawLine, the line starts one step past this point
183
+ const boxStartCanvas = copyCanvas(graph.canvas)
184
+ if (bundle.type === 'fan-in' && pathToJunction.length >= 2) {
185
+ const firstPoint = drawingPath[0]!
186
+ const dir = determineDirection(pathToJunction[0]!, pathToJunction[1]!)
187
+
188
+ const junction = useAscii ? '+' : null
189
+ if (dirEquals(dir, Up))
190
+ write(boxStartCanvas, firstPoint.x, firstPoint.y, junction ?? '┴')
191
+ else if (dirEquals(dir, Down))
192
+ write(boxStartCanvas, firstPoint.x, firstPoint.y, junction ?? '┬')
193
+ else if (dirEquals(dir, Left))
194
+ write(boxStartCanvas, firstPoint.x, firstPoint.y, junction ?? '┤')
195
+ else if (dirEquals(dir, Right))
196
+ write(boxStartCanvas, firstPoint.x, firstPoint.y, junction ?? '├')
197
+ }
198
+
199
+ // Label canvas (bundled edges typically don't have labels, but handle it)
200
+ const labelCanvas = copyCanvas(graph.canvas)
201
+
202
+ return [pathCanvas, boxStartCanvas, empty, empty, cornersCanvas, labelCanvas]
203
+ }
204
+
205
+ /**
206
+ * Draw the shared path segment of a bundle (junction → target for fan-in,
207
+ * source → junction for fan-out).
208
+ */
209
+ export function drawBundleSharedPath(
210
+ graph: AsciiGraph,
211
+ bundle: EdgeBundle,
212
+ ): [Canvas, Canvas] {
213
+ const pathCanvas = copyCanvas(graph.canvas)
214
+ const cornersCanvas = copyCanvas(graph.canvas)
215
+
216
+ if (bundle.sharedPath.length < 2) {
217
+ return [pathCanvas, cornersCanvas]
218
+ }
219
+
220
+ const useAscii = graph.config.useAscii
221
+ const style = bundle.edges[0]?.style ?? 'solid'
222
+ const graphDir = graph.config.graphDirection
223
+
224
+ // Convert grid coords to drawing coords
225
+ // For fan-in: last point is at target node border
226
+ // For fan-out: first point is at source node border
227
+ const drawingPath = bundle.sharedPath.map((gc, idx) => {
228
+ if (bundle.type === 'fan-in' && idx === bundle.sharedPath.length - 1) {
229
+ // Last point: use target node's actual border position (entry from above/left)
230
+ const entryDir = graphDir === 'TD' ? Up : Left
231
+ return getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir)
232
+ }
233
+ if (bundle.type === 'fan-out' && idx === 0) {
234
+ // First point: use source node's actual border position (exit going down/right)
235
+ const exitDir = graphDir === 'TD' ? Down : Right
236
+ return getNodeAttachmentPoint(graph, bundle.sharedNode, exitDir)
237
+ }
238
+ return gridToDrawingCoord(graph, gc)
239
+ })
240
+
241
+ // Draw line segments with appropriate offsets
242
+ for (let i = 1; i < drawingPath.length; i++) {
243
+ const from = drawingPath[i - 1]!
244
+ const to = drawingPath[i]!
245
+ if (!drawingCoordEquals(from, to)) {
246
+ // Always skip both endpoints (offset 1, -1), matching non-bundled drawPath.
247
+ drawLine(pathCanvas, from, to, 1, -1, useAscii, style)
248
+ }
249
+ }
250
+
251
+ // Draw corners at path bends
252
+ for (let idx = 1; idx < bundle.sharedPath.length - 1; idx++) {
253
+ const coord = bundle.sharedPath[idx]!
254
+ const dc = gridToDrawingCoord(graph, coord)
255
+ const prevDir = determineDirection(bundle.sharedPath[idx - 1]!, coord)
256
+ const nextDir = determineDirection(coord, bundle.sharedPath[idx + 1]!)
257
+
258
+ let corner: string
259
+ if (!useAscii) {
260
+ if (
261
+ (dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||
262
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))
263
+ ) {
264
+ corner = '┐'
265
+ } else if (
266
+ (dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||
267
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))
268
+ ) {
269
+ corner = '┘'
270
+ } else if (
271
+ (dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||
272
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))
273
+ ) {
274
+ corner = '┌'
275
+ } else if (
276
+ (dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||
277
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))
278
+ ) {
279
+ corner = '└'
280
+ } else {
281
+ corner = '+'
282
+ }
283
+ } else {
284
+ corner = '+'
285
+ }
286
+
287
+ write(cornersCanvas, dc.x, dc.y, corner)
288
+ }
289
+
290
+ return [pathCanvas, cornersCanvas]
291
+ }
292
+
293
+ /**
294
+ * Draw the arrowhead for a fan-in bundle (single arrowhead at the shared target).
295
+ */
296
+ export function drawBundleArrowhead(
297
+ graph: AsciiGraph,
298
+ bundle: EdgeBundle,
299
+ ): Canvas {
300
+ const canvas = copyCanvas(graph.canvas)
301
+
302
+ if (bundle.sharedPath.length < 2) return canvas
303
+
304
+ // Get the last segment direction
305
+ const lastIdx = bundle.sharedPath.length - 1
306
+ const secondLast = bundle.sharedPath[lastIdx - 1]!
307
+ const last = bundle.sharedPath[lastIdx]!
308
+ const dir = determineDirection(secondLast, last)
309
+
310
+ // Get drawing coord 1 char outside the target node's border (not on the border itself).
311
+ // This matches non-bundled edges where drawPath uses offsetTo=-1 and the arrowhead
312
+ // sits at the last drawn point (1 char before the border).
313
+ const graphDir = graph.config.graphDirection
314
+ const entryDir = graphDir === 'TD' ? Up : Left
315
+ const dc = getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir)
316
+ // Offset 1 char away from the box border so arrowhead sits outside the box
317
+ if (graphDir === 'TD') dc.y -= 1
318
+ else dc.x -= 1
319
+
320
+ // Draw arrowhead. A fan-in bundle merges several edges into one shared
321
+ // trunk/junction, so there's only one arrowhead to draw for all of them —
322
+ // it only gets a circle/cross glyph when every bundled edge agrees on the
323
+ // same marker; a mix (e.g. one `-->`, one `--o`) falls back to the plain
324
+ // directional arrowhead rather than guessing which edge's marker "wins".
325
+ // See issue #330.
326
+ const firstMarker = bundle.edges[0]?.endMarker
327
+ const sharedMarker = bundle.edges.every((e) => e.endMarker === firstMarker)
328
+ ? firstMarker
329
+ : undefined
330
+ const markerChar = markerArrowChar(graph.config.useAscii, sharedMarker)
331
+
332
+ let char: string
333
+ if (markerChar !== undefined) {
334
+ char = markerChar
335
+ } else if (!graph.config.useAscii) {
336
+ if (dirEquals(dir, Up)) char = '▲'
337
+ else if (dirEquals(dir, Down)) char = '▼'
338
+ else if (dirEquals(dir, Left)) char = '◄'
339
+ else if (dirEquals(dir, Right)) char = '►'
340
+ else char = '▼' // default
341
+ } else {
342
+ if (dirEquals(dir, Up)) char = '^'
343
+ else if (dirEquals(dir, Down)) char = 'v'
344
+ else if (dirEquals(dir, Left)) char = '<'
345
+ else if (dirEquals(dir, Right)) char = '>'
346
+ else char = 'v' // default
347
+ }
348
+
349
+ write(canvas, dc.x, dc.y, char)
350
+ return canvas
351
+ }
352
+
353
+ /**
354
+ * Draw the arrowhead for a single edge in a fan-out bundle.
355
+ */
356
+ export function drawBundledEdgeArrowhead(
357
+ graph: AsciiGraph,
358
+ edge: AsciiEdge,
359
+ ): Canvas {
360
+ const canvas = copyCanvas(graph.canvas)
361
+
362
+ if (!edge.pathToJunction || edge.pathToJunction.length < 2) return canvas
363
+
364
+ // Get the last segment direction
365
+ const lastIdx = edge.pathToJunction.length - 1
366
+ const secondLast = edge.pathToJunction[lastIdx - 1]!
367
+ const last = edge.pathToJunction[lastIdx]!
368
+ const dir = determineDirection(secondLast, last)
369
+
370
+ // Get drawing coord 1 char outside the target node's border
371
+ const graphDir = graph.config.graphDirection
372
+ const entryDir = graphDir === 'TD' ? Up : Left
373
+ const dc = getNodeAttachmentPoint(graph, edge.to, entryDir)
374
+ // Offset 1 char away from the box border so arrowhead sits outside the box
375
+ if (graphDir === 'TD') dc.y -= 1
376
+ else dc.x -= 1
377
+
378
+ // Draw arrowhead — this is a single edge (unlike the fan-in case above),
379
+ // so its own endMarker unambiguously picks the glyph. See issue #330.
380
+ const markerChar = markerArrowChar(graph.config.useAscii, edge.endMarker)
381
+
382
+ let char: string
383
+ if (markerChar !== undefined) {
384
+ char = markerChar
385
+ } else if (!graph.config.useAscii) {
386
+ if (dirEquals(dir, Up)) char = '▲'
387
+ else if (dirEquals(dir, Down)) char = '▼'
388
+ else if (dirEquals(dir, Left)) char = '◄'
389
+ else if (dirEquals(dir, Right)) char = '►'
390
+ else char = '▼' // default
391
+ } else {
392
+ if (dirEquals(dir, Up)) char = '^'
393
+ else if (dirEquals(dir, Down)) char = 'v'
394
+ else if (dirEquals(dir, Left)) char = '<'
395
+ else if (dirEquals(dir, Right)) char = '>'
396
+ else char = 'v' // default
397
+ }
398
+
399
+ write(canvas, dc.x, dc.y, char)
400
+ return canvas
401
+ }
402
+
403
+ /** Reverse a cardinal direction (start arrowheads point back at the node they exit). */
404
+ function reverseDirection(dir: Direction): Direction {
405
+ if (dirEquals(dir, Up)) return Down
406
+ if (dirEquals(dir, Down)) return Up
407
+ if (dirEquals(dir, Left)) return Right
408
+ if (dirEquals(dir, Right)) return Left
409
+ return dir
410
+ }
411
+
412
+ /**
413
+ * Draw the start-side arrowhead for a fan-out bundle (single arrowhead at
414
+ * the shared source, mirroring drawBundleArrowhead's shared-target end).
415
+ */
416
+ export function drawBundleArrowheadStart(
417
+ graph: AsciiGraph,
418
+ bundle: EdgeBundle,
419
+ ): Canvas {
420
+ const canvas = copyCanvas(graph.canvas)
421
+
422
+ if (bundle.sharedPath.length < 2) return canvas
423
+
424
+ // Get the first segment direction (source -> next point) and reverse it:
425
+ // the start arrowhead points back into the source, opposite the trunk's
426
+ // direction of travel.
427
+ const first = bundle.sharedPath[0]!
428
+ const second = bundle.sharedPath[1]!
429
+ const dir = reverseDirection(determineDirection(first, second))
430
+
431
+ // Get drawing coord 1 char outside the source node's border (not on the
432
+ // border itself), on the side the trunk exits from.
433
+ const graphDir = graph.config.graphDirection
434
+ const exitDir = graphDir === 'TD' ? Down : Right
435
+ const dc = getNodeAttachmentPoint(graph, bundle.sharedNode, exitDir)
436
+ // Offset 1 char away from the box border so arrowhead sits outside the box
437
+ if (graphDir === 'TD') dc.y += 1
438
+ else dc.x += 1
439
+
440
+ // Draw arrowhead
441
+ let char: string
442
+ if (!graph.config.useAscii) {
443
+ if (dirEquals(dir, Up)) char = '▲'
444
+ else if (dirEquals(dir, Down)) char = '▼'
445
+ else if (dirEquals(dir, Left)) char = '◄'
446
+ else if (dirEquals(dir, Right)) char = '►'
447
+ else char = '▲' // default
448
+ } else {
449
+ if (dirEquals(dir, Up)) char = '^'
450
+ else if (dirEquals(dir, Down)) char = 'v'
451
+ else if (dirEquals(dir, Left)) char = '<'
452
+ else if (dirEquals(dir, Right)) char = '>'
453
+ else char = '^' // default
454
+ }
455
+
456
+ write(canvas, dc.x, dc.y, char)
457
+ return canvas
458
+ }
459
+
460
+ /**
461
+ * Draw the start-side arrowhead for a single edge in a fan-in bundle
462
+ * (mirroring drawBundledEdgeArrowhead's per-edge target end).
463
+ */
464
+ export function drawBundledEdgeArrowheadStart(
465
+ graph: AsciiGraph,
466
+ edge: AsciiEdge,
467
+ ): Canvas {
468
+ const canvas = copyCanvas(graph.canvas)
469
+
470
+ if (!edge.pathToJunction || edge.pathToJunction.length < 2) return canvas
471
+
472
+ // Get the first segment direction and reverse it: the start arrowhead
473
+ // points back into the edge's own source, opposite its direction of travel.
474
+ const first = edge.pathToJunction[0]!
475
+ const second = edge.pathToJunction[1]!
476
+ const dir = reverseDirection(determineDirection(first, second))
477
+
478
+ // Get drawing coord 1 char outside the source node's border, on the side
479
+ // this edge actually exits from.
480
+ const dc = getNodeAttachmentPoint(graph, edge.from, edge.startDir)
481
+ // Offset 1 char away from the box border so arrowhead sits outside the box
482
+ if (dirEquals(edge.startDir, Up)) dc.y -= 1
483
+ else if (dirEquals(edge.startDir, Down)) dc.y += 1
484
+ else if (dirEquals(edge.startDir, Left)) dc.x -= 1
485
+ else if (dirEquals(edge.startDir, Right)) dc.x += 1
486
+
487
+ // Draw arrowhead
488
+ let char: string
489
+ if (!graph.config.useAscii) {
490
+ if (dirEquals(dir, Up)) char = '▲'
491
+ else if (dirEquals(dir, Down)) char = '▼'
492
+ else if (dirEquals(dir, Left)) char = '◄'
493
+ else if (dirEquals(dir, Right)) char = '►'
494
+ else char = '▼' // default
495
+ } else {
496
+ if (dirEquals(dir, Up)) char = '^'
497
+ else if (dirEquals(dir, Down)) char = 'v'
498
+ else if (dirEquals(dir, Left)) char = '<'
499
+ else if (dirEquals(dir, Right)) char = '>'
500
+ else char = 'v' // default
501
+ }
502
+
503
+ write(canvas, dc.x, dc.y, char)
504
+ return canvas
505
+ }
506
+
507
+ /**
508
+ * Draw the junction character where bundled edges merge/split.
509
+ *
510
+ * Analyzes actual connecting directions to choose the correct character:
511
+ * - ┼ (cross): lines from all 4 directions
512
+ * - ┬ (T down): lines from left, right, and down
513
+ * - ┴ (T up): lines from left, right, and up
514
+ * - ├ (T right): lines from up, down, and right
515
+ * - ┤ (T left): lines from up, down, and left
516
+ */
517
+ export function drawJunctionCharacter(
518
+ graph: AsciiGraph,
519
+ bundle: EdgeBundle,
520
+ ): Canvas {
521
+ const canvas = copyCanvas(graph.canvas)
522
+
523
+ if (!bundle.junctionPoint) return canvas
524
+
525
+ const dc = gridToDrawingCoord(graph, bundle.junctionPoint)
526
+ const useAscii = graph.config.useAscii
527
+
528
+ // Analyze what directions actually connect to the junction
529
+ let hasUp = false
530
+ let hasDown = false
531
+ let hasLeft = false
532
+ let hasRight = false
533
+
534
+ // Check shared path direction (where the line continues to/from the shared node)
535
+ if (bundle.sharedPath.length >= 2) {
536
+ // For fan-in: shared path goes FROM junction TO target (index 0 is junction)
537
+ // For fan-out: shared path goes FROM source TO junction (last index is junction)
538
+ const junctionIdx =
539
+ bundle.type === 'fan-in' ? 0 : bundle.sharedPath.length - 1
540
+ const adjacentIdx =
541
+ bundle.type === 'fan-in' ? 1 : bundle.sharedPath.length - 2
542
+ const sharedDir = determineDirection(
543
+ bundle.sharedPath[junctionIdx]!,
544
+ bundle.sharedPath[adjacentIdx]!,
545
+ )
546
+ // This is the direction the shared path GOES from junction
547
+ if (dirEquals(sharedDir, Down)) hasDown = true
548
+ else if (dirEquals(sharedDir, Up)) hasUp = true
549
+ else if (dirEquals(sharedDir, Right)) hasRight = true
550
+ else if (dirEquals(sharedDir, Left)) hasLeft = true
551
+ }
552
+
553
+ // Check each edge's path direction at the junction
554
+ for (const edge of bundle.edges) {
555
+ if (edge.pathToJunction && edge.pathToJunction.length >= 2) {
556
+ // For fan-in: pathToJunction goes FROM source TO junction (last is junction)
557
+ // For fan-out: pathToJunction goes FROM junction TO target (first is junction)
558
+ const junctionIdx =
559
+ bundle.type === 'fan-in' ? edge.pathToJunction.length - 1 : 0
560
+ const adjacentIdx =
561
+ bundle.type === 'fan-in' ? edge.pathToJunction.length - 2 : 1
562
+
563
+ const arrivalDir = determineDirection(
564
+ edge.pathToJunction[adjacentIdx]!,
565
+ edge.pathToJunction[junctionIdx]!,
566
+ )
567
+ // This is the direction the edge ARRIVES at junction from
568
+ // e.g., if arrivalDir is Right, the line comes FROM the left
569
+ if (dirEquals(arrivalDir, Down))
570
+ hasUp = true // arrived going down = came from up
571
+ else if (dirEquals(arrivalDir, Up)) hasDown = true
572
+ else if (dirEquals(arrivalDir, Right)) hasLeft = true
573
+ else if (dirEquals(arrivalDir, Left)) hasRight = true
574
+ }
575
+ }
576
+
577
+ // Select character based on connected directions
578
+ let char: string
579
+ if (!useAscii) {
580
+ if (hasUp && hasDown && hasLeft && hasRight) {
581
+ char = '┼' // cross - all 4 directions
582
+ } else if (hasDown && hasLeft && hasRight && !hasUp) {
583
+ char = '┬' // T pointing down
584
+ } else if (hasUp && hasLeft && hasRight && !hasDown) {
585
+ char = '┴' // T pointing up
586
+ } else if (hasUp && hasDown && hasRight && !hasLeft) {
587
+ char = '├' // T pointing right
588
+ } else if (hasUp && hasDown && hasLeft && !hasRight) {
589
+ char = '┤' // T pointing left
590
+ } else if (hasLeft && hasRight) {
591
+ char = '─' // horizontal only
592
+ } else if (hasUp && hasDown) {
593
+ char = '│' // vertical only
594
+ } else if (hasDown && hasRight) {
595
+ char = '┌' // corner
596
+ } else if (hasDown && hasLeft) {
597
+ char = '┐'
598
+ } else if (hasUp && hasRight) {
599
+ char = '└'
600
+ } else if (hasUp && hasLeft) {
601
+ char = '┘'
602
+ } else {
603
+ char = '┼' // fallback
604
+ }
605
+ } else {
606
+ char = '+'
607
+ }
608
+
609
+ write(canvas, dc.x, dc.y, char)
610
+ return canvas
611
+ }